From f64b147451755e93950786111e0d5ae075a9d9f2 Mon Sep 17 00:00:00 2001 From: Carlos Lopez Date: Fri, 28 Aug 2026 08:53:14 +0200 Subject: [PATCH 1/4] project apikeys --- bun.lock | 101 +++++++ src/app.d.ts | 3 +- src/hooks.server.ts | 46 ++- src/lib/components/AppSidebar.svelte | 2 +- src/lib/database/schemas.ts | 6 +- src/lib/i18n/locales/en.json | 4 + src/lib/i18n/locales/es.json | 4 + .../auth/application/apikeys.service.test.ts | 264 ++++++++++++------ .../auth/application/apikeys.service.ts | 231 +++++++++++---- .../auth/application/auth.service.test.ts | 7 +- .../auth/application/cancan.service.test.ts | 41 +++ .../auth/application/cancan.service.ts | 23 +- .../application/user-access.service.test.ts | 14 +- .../auth/application/user-access.service.ts | 8 +- src/modules/auth/domain/entities.ts | 14 + src/modules/auth/index.ts | 2 +- .../repositories/apikey.repository.ts | 20 +- src/routes/api/code-report/scan/+server.ts | 61 ++-- .../server-access-keys/+page.server.ts | 128 ++++++--- .../settings/server-access-keys/+page.svelte | 91 ++++-- 20 files changed, 801 insertions(+), 269 deletions(-) diff --git a/bun.lock b/bun.lock index 5755b2a..07faa29 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@lucide/svelte": "^1.34.0", "chart.js": "^4.5.1", "nodemailer": "^9.0.6", + "svelte-i18n": "^4.0.1", }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -154,6 +155,16 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], + + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@2.2.7", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@2.11.4", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/icu-skeleton-parser": "1.8.16", "tslib": "^2.8.0" } }, "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@1.8.16", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "tslib": "^2.8.0" } }, "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], + "@getgitops/gitdb": ["@getgitops/gitdb@0.8.0", "", {}, "sha512-OYeCchlR1n91UBuDA789+4zgl9i+27DzzLr6djahQlLd7haYrO+LQqaRoJQHVKBmn0SOYPsCY7R+Kc6YeGQOgA=="], "@google-cloud/paginator": ["@google-cloud/paginator@7.0.1", "", { "dependencies": { "extend": "^3.0.2" } }, "sha512-k32cWlHAF8yTgg8rciLI8mPMI6UzuJdKp53YRxISRwMFxUl2FYplvs+Mr2UHxKn0W7rXsqZnUZy73AOJFDP8iA=="], @@ -448,6 +459,8 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "cli-color": ["cli-color@2.0.4", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.64", "es6-iterator": "^2.0.3", "memoizee": "^0.4.15", "timers-ext": "^0.1.7" } }, "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], @@ -460,10 +473,14 @@ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -486,6 +503,14 @@ "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="], + + "es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="], + + "es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="], + + "es6-weak-map": ["es6-weak-map@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.46", "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.1" } }, "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA=="], + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -504,6 +529,8 @@ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], @@ -518,10 +545,14 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -562,6 +593,10 @@ "globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="], + "globalyzer": ["globalyzer@0.1.0", "", {}, "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + "google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], "google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="], @@ -590,6 +625,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "intl-messageformat": ["intl-messageformat@10.7.18", "", { "dependencies": { "@formatjs/ecma402-abstract": "2.3.6", "@formatjs/fast-memoize": "2.2.7", "@formatjs/icu-messageformat-parser": "2.11.4", "tslib": "^2.8.0" } }, "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], @@ -598,6 +635,8 @@ "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], + "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -666,12 +705,16 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lru-queue": ["lru-queue@0.1.0", "", { "dependencies": { "es5-ext": "~0.10.2" } }, "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "memoizee": ["memoizee@0.4.17", "", { "dependencies": { "d": "^1.0.2", "es5-ext": "^0.10.64", "es6-weak-map": "^2.0.3", "event-emitter": "^0.3.5", "is-promise": "^2.2.2", "lru-queue": "^0.1.0", "next-tick": "^1.1.0", "timers-ext": "^0.1.7" } }, "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], @@ -686,6 +729,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -794,12 +839,18 @@ "svelte-eslint-parser": ["svelte-eslint-parser@1.8.1", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA=="], + "svelte-i18n": ["svelte-i18n@4.0.1", "", { "dependencies": { "cli-color": "^2.0.3", "deepmerge": "^4.2.2", "esbuild": "^0.19.2", "estree-walker": "^2", "intl-messageformat": "^10.5.3", "sade": "^1.8.1", "tiny-glob": "^0.2.9" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5" }, "bin": { "svelte-i18n": "dist/cli.js" } }, "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "teeny-request": ["teeny-request@11.0.1", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "stream-events": "^1.0.5" } }, "sha512-bNr5j2YjSdajgCVsp+8JVjRf1uHIbpNISLeKp/7V1NnVMX3Gh6H/kECNGlm2Q3I68ACODQ0iv6aZ3Rvm8WgLGA=="], + "timers-ext": ["timers-ext@0.1.8", "", { "dependencies": { "es5-ext": "^0.10.64", "next-tick": "^1.1.0" } }, "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww=="], + + "tiny-glob": ["tiny-glob@0.2.9", "", { "dependencies": { "globalyzer": "0.1.0", "globrex": "^0.1.2" } }, "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], @@ -816,6 +867,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], @@ -892,6 +945,8 @@ "svelte-eslint-parser/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "svelte-i18n/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], + "teeny-request/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -915,5 +970,51 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "svelte-i18n/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], + + "svelte-i18n/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], + + "svelte-i18n/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], + + "svelte-i18n/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], + + "svelte-i18n/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], + + "svelte-i18n/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], + + "svelte-i18n/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], + + "svelte-i18n/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], + + "svelte-i18n/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], + + "svelte-i18n/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], + + "svelte-i18n/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], + + "svelte-i18n/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], + + "svelte-i18n/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], + + "svelte-i18n/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], + + "svelte-i18n/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], + + "svelte-i18n/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], + + "svelte-i18n/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], + + "svelte-i18n/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], + + "svelte-i18n/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], + + "svelte-i18n/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], + + "svelte-i18n/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], + + "svelte-i18n/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], + + "svelte-i18n/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], } } diff --git a/src/app.d.ts b/src/app.d.ts index 13e444f..5194ef6 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,9 +1,10 @@ -import type { AuthenticatedUser } from '$modules/auth/domain/entities'; +import type { AuthenticatedApiKey, AuthenticatedUser } from '$modules/auth/domain/entities'; declare global { namespace App { interface Locals { user?: AuthenticatedUser | null; + apiKey?: AuthenticatedApiKey | null; } } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index cf00467..0ff8226 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,5 @@ import type { Handle } from '@sveltejs/kit'; -import { authService, cancanService, ensureAuthReady } from '$modules/auth'; +import { apiKeysService, authService, cancanService, ensureAuthReady } from '$modules/auth'; import { organizationService } from '$modules/organization'; import { projectService } from '$modules/projects'; import { isBootstrapCompleted, refreshBootstrapState } from '$lib/server/bootstrap'; @@ -20,9 +20,18 @@ serverReady.catch((error) => { markServerFailed(error); }); -const authWithToken = async (_token: string) => { - return true; -}; +function bearerToken(request: Request): string | null { + const header = request.headers.get('Authorization') ?? ''; + const match = header.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() || null : null; +} + +function unauthorized(message: string) { + return new Response(JSON.stringify({ error: message }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }); +} export const handle: Handle = async ({ event, resolve }) => { const pathname = event.url.pathname; @@ -68,18 +77,28 @@ export const handle: Handle = async ({ event, resolve }) => { return resolve(event); } - if (isApiRequest) { - const token = event.request.headers.get('Authorization') || ''; - if (!token || token.trim() === '') { - return new Response(null, { status: 401 }); + // machine-to-machine path: `Authorization: Bearer gvs_...` resolves a project + role identity. + // it coexists with the session cookie path below, which still serves browser requests to /api. + const token = bearerToken(event.request); + + if (token) { + // a project key is confined to its own project and must never reach the admin/global UI areas + if (!isApiRequest) { + return unauthorized('API keys can only be used on /api routes'); } - const isAuthenticated = await authWithToken(token); + const apiKey = await apiKeysService.authenticate(token); - if (!isAuthenticated) { - return new Response(null, { status: 401 }); + if (!apiKey) { + return unauthorized('Invalid API key'); } - return runWithActor({ name: 'apikey', email: 'apikey@gitops.local' }, () => resolve(event)); + + event.locals.apiKey = apiKey; + + return runWithActor( + { name: `apikey:${apiKey.name}`, email: `apikey+${apiKey.id}@gitops.local` }, + () => resolve(event), + ); } const sessionCookie = event.cookies.get('pos_session'); @@ -90,6 +109,9 @@ export const handle: Handle = async ({ event, resolve }) => { } if (!currentUser) { + if (isApiRequest) { + return unauthorized('Authentication required'); + } return new Response(null, { status: 302, headers: { location: '/auth/login' } }); } diff --git a/src/lib/components/AppSidebar.svelte b/src/lib/components/AppSidebar.svelte index e171cb3..23769bb 100644 --- a/src/lib/components/AppSidebar.svelte +++ b/src/lib/components/AppSidebar.svelte @@ -170,7 +170,7 @@ { label: $_('sidebar.items.audit'), href: `${projectBase}/settings/audit`, icon: ScrollText }, { label: $_('sidebar.items.serverKeys'), - href: `${projectBase}/settings/server-keys`, + href: `${projectBase}/settings/server-access-keys`, icon: Shield, }, ], diff --git a/src/lib/database/schemas.ts b/src/lib/database/schemas.ts index 6ecc9e9..efb4257 100644 --- a/src/lib/database/schemas.ts +++ b/src/lib/database/schemas.ts @@ -40,10 +40,14 @@ export const RoleEntity = entity('roles', { .$defaultFn(() => new Date().toISOString()), }); +// exactly one of `userId` (personal key) or `projectId` (machine-to-machine project key) is set; +// `roleId` is only meaningful for project keys and points to a project-scoped role export const ApiKeyEntity = entity('api_keys', { id: uuid().primaryKey(), - userId: uuid().notNull(), + userId: uuid(), projectId: uuid(), + roleId: uuid(), + createdByUserId: uuid(), name: text().notNull(), keyPrefix: text().notNull(), keyHash: text().notNull(), diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 296e1b5..df0963b 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -428,6 +428,10 @@ "expired": "Expired", "active": "Active", "revoke": "Revoke", + "rotate": "Rotate", + "role": "Project role", + "roleHint": "The key can only do what this project role allows, inside this project.", + "lastUsed": "Last used", "createServerAccessKey": "Create server access key", "expiration": "Expiration", "copy": "Copy", diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index c849371..942e440 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -428,6 +428,10 @@ "expired": "Expirada", "active": "Activa", "revoke": "Revocar", + "rotate": "Rotar", + "role": "Rol del proyecto", + "roleHint": "La key solo puede hacer lo que permita este rol, dentro de este proyecto.", + "lastUsed": "Último uso", "createServerAccessKey": "Crear server access key", "expiration": "Expiración", "copy": "Copiar", diff --git a/src/modules/auth/application/apikeys.service.test.ts b/src/modules/auth/application/apikeys.service.test.ts index b3e0a0c..562504c 100644 --- a/src/modules/auth/application/apikeys.service.test.ts +++ b/src/modules/auth/application/apikeys.service.test.ts @@ -2,6 +2,38 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiKeysService } from './apikeys.service'; import type { ApiKeyView } from '../domain/entities'; +function apiKey(overrides: Partial = {}): ApiKeyView { + return { + id: 'key-1', + name: 'Deploy', + keyPrefix: 'gvs_121212', + userId: 'user-1', + projectId: null, + roleId: null, + createdByUserId: 'user-1', + expiresAt: null, + lastUsedAt: null, + revokedAt: null, + createdAt: '2026-08-18T00:00:00.000Z', + ...overrides, + }; +} + +function projectRole(overrides: Record = {}) { + const role = { + id: 'role-1', + name: 'Project Developer', + slug: 'project-developer', + scope: 'project', + organizationId: null, + projectId: 'project-1', + project: null, + permissions: ['project:codereport:reports:create'], + ...overrides, + }; + return { ...role, toJson: () => role }; +} + function createRepositoryMock(): any { return { listByUser: vi.fn(), @@ -17,6 +49,10 @@ function createRepositoryMock(): any { }; } +function createRoleRepositoryMock(role: unknown = projectRole()): any { + return { findById: vi.fn(async () => role) }; +} + describe('ApiKeysService', () => { beforeEach(() => { vi.clearAllMocks(); @@ -24,19 +60,7 @@ describe('ApiKeysService', () => { it('lists keys through the repository', async () => { const repository = createRepositoryMock(); - const keys: ApiKeyView[] = [ - { - id: 'key-1', - name: 'Deploy', - keyPrefix: 'gvs_12', - projectId: null, - expiresAt: null, - lastUsedAt: null, - revokedAt: null, - createdAt: '2026-08-18T00:00:00.000Z', - }, - ]; - + const keys = [apiKey()]; repository.listByUser.mockImplementation(async () => keys); const service = new ApiKeysService(repository); @@ -47,16 +71,7 @@ describe('ApiKeysService', () => { it('validates an active api key token', async () => { const repository = createRepositoryMock(); - repository.findValidByHash.mockImplementation(async () => ({ - id: 'key-1', - name: 'Deploy', - keyPrefix: 'gvs_12', - projectId: null, - expiresAt: null, - lastUsedAt: null, - revokedAt: null, - createdAt: '2026-08-18T00:00:00.000Z', - })); + repository.findValidByHash.mockImplementation(async () => apiKey()); const service = new ApiKeysService(repository); @@ -64,11 +79,12 @@ describe('ApiKeysService', () => { expect(repository.findValidByHash).toHaveBeenCalledWith(expect.any(String)); }); - it('rejects empty api key tokens', async () => { + it('rejects tokens that are empty or not gitops tokens', async () => { const repository = createRepositoryMock(); const service = new ApiKeysService(repository); await expect(service.validateApiKey(' ')).resolves.toBe(false); + await expect(service.validateApiKey('some-other-token')).resolves.toBe(false); expect(repository.findValidByHash).not.toHaveBeenCalled(); }); @@ -79,59 +95,129 @@ describe('ApiKeysService', () => { const service = new ApiKeysService(repository); await expect(service.validateApiKey('gvs_missing')).resolves.toBe(false); - expect(repository.findValidByHash).toHaveBeenCalledWith(expect.any(String)); }); - it('creates a key with a capped prefix and hashed token', async () => { + it('records the usage of a resolved key', async () => { + const repository = createRepositoryMock(); + repository.findValidByHash.mockImplementation(async () => apiKey()); + + const service = new ApiKeysService(repository); + await service.resolveApiKey('gvs_121212'); + + expect(repository.touchLastUsed).toHaveBeenCalledWith('key-1'); + }); + + it('creates a user key with a hashed token and no project scope', async () => { const repository = createRepositoryMock(); const service = new ApiKeysService(repository); const result = await service.createApiKey('user-1', 'Deploy', '2026-12-31T00:00:00.000Z'); expect(result.token.startsWith('gvs_')).toBe(true); - expect(result.key).toMatchObject({ - id: expect.any(String), - name: 'Deploy', - keyPrefix: result.token.slice(0, 6), - expiresAt: '2026-12-31T00:00:00.000Z', - lastUsedAt: null, - revokedAt: null, - }); - expect(repository.create).toHaveBeenCalledWith({ id: expect.any(String), userId: 'user-1', projectId: null, + roleId: null, + createdByUserId: 'user-1', name: 'Deploy', - keyPrefix: result.token.slice(0, 6), + keyPrefix: result.token.slice(0, 10), keyHash: expect.any(String), expiresAt: '2026-12-31T00:00:00.000Z', }); }); + it('creates a project key bound to a project role and no user', async () => { + const repository = createRepositoryMock(); + const service = new ApiKeysService(repository, createRoleRepositoryMock()); + + const result = await service.createProjectApiKey({ + projectId: 'project-1', + roleId: 'role-1', + name: 'CI pipeline', + expiresAt: null, + createdByUserId: 'user-1', + }); + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: null, + projectId: 'project-1', + roleId: 'role-1', + createdByUserId: 'user-1', + }), + ); + expect(result.key.userId).toBeNull(); + }); + + it('rejects a project key whose role belongs to another project', async () => { + const repository = createRepositoryMock(); + const service = new ApiKeysService( + repository, + createRoleRepositoryMock(projectRole({ projectId: 'project-2' })), + ); + + await expect( + service.createProjectApiKey({ + projectId: 'project-1', + roleId: 'role-1', + name: 'CI pipeline', + expiresAt: null, + createdByUserId: 'user-1', + }), + ).rejects.toThrow('The selected role does not belong to this project'); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('authenticates a project key into a project + role identity', async () => { + const repository = createRepositoryMock(); + repository.findValidByHash.mockImplementation(async () => + apiKey({ userId: null, projectId: 'project-1', roleId: 'role-1' }), + ); + const projectLookup = { + getProject: vi.fn(async () => ({ id: 'project-1', organization: { id: 'org-1' } })), + }; + + const service = new ApiKeysService(repository, createRoleRepositoryMock(), projectLookup); + const identity = await service.authenticate('gvs_121212'); + + expect(identity).toMatchObject({ + id: 'key-1', + projectId: 'project-1', + organizationId: 'org-1', + userId: null, + }); + expect(identity?.role?.slug).toBe('project-developer'); + }); + + it('does not authenticate a project key whose role no longer belongs to the project', async () => { + const repository = createRepositoryMock(); + repository.findValidByHash.mockImplementation(async () => + apiKey({ userId: null, projectId: 'project-1', roleId: 'role-1' }), + ); + + const service = new ApiKeysService( + repository, + createRoleRepositoryMock(projectRole({ projectId: 'project-2' })), + ); + + await expect(service.authenticate('gvs_121212')).resolves.toBeNull(); + }); + + it('does not authenticate a revoked or expired key', async () => { + const repository = createRepositoryMock(); + repository.findValidByHash.mockImplementation(async () => null); + + const service = new ApiKeysService(repository, createRoleRepositoryMock()); + + await expect(service.authenticate('gvs_121212')).resolves.toBeNull(); + }); + it('revokes an active key and rejects already revoked keys', async () => { const repository = createRepositoryMock(); repository.findById - .mockImplementationOnce(async () => ({ - id: 'key-1', - name: 'Deploy', - keyPrefix: 'gvs_12', - projectId: null, - expiresAt: null, - lastUsedAt: null, - revokedAt: null, - createdAt: '2026-08-18T00:00:00.000Z', - })) - .mockImplementationOnce(async () => ({ - id: 'key-1', - name: 'Deploy', - keyPrefix: 'gvs_12', - projectId: null, - expiresAt: null, - lastUsedAt: null, - revokedAt: '2026-08-18T00:10:00.000Z', - createdAt: '2026-08-18T00:00:00.000Z', - })); + .mockImplementationOnce(async () => apiKey()) + .mockImplementationOnce(async () => apiKey({ revokedAt: '2026-08-18T00:10:00.000Z' })); const service = new ApiKeysService(repository); @@ -142,50 +228,62 @@ describe('ApiKeysService', () => { expect(repository.revoke).toHaveBeenCalledTimes(1); }); - it('regenerates the same record instead of creating a new one', async () => { + it('revokes a project key only from its own project', async () => { const repository = createRepositoryMock(); - repository.findById.mockImplementation(async () => ({ - id: 'key-1', - name: 'Deploy', - keyPrefix: 'gvs_old', - projectId: null, - expiresAt: '2026-12-31T00:00:00.000Z', - lastUsedAt: null, - revokedAt: null, - createdAt: '2026-08-18T00:00:00.000Z', - })); + repository.findByIdAny.mockImplementation(async () => + apiKey({ userId: null, projectId: 'project-1', roleId: 'role-1' }), + ); + + const service = new ApiKeysService(repository); + + await expect(service.revokeProjectApiKey('project-2', 'key-1')).rejects.toThrow( + 'API key not found', + ); + await service.revokeProjectApiKey('project-1', 'key-1'); + expect(repository.revokeAny).toHaveBeenCalledWith('key-1'); + }); + + it('rotates the same record instead of creating a new one', async () => { + const repository = createRepositoryMock(); + repository.findById.mockImplementation(async () => + apiKey({ expiresAt: '2026-12-31T00:00:00.000Z' }), + ); const service = new ApiKeysService(repository); const result = await service.regenerateApiKey('user-1', 'key-1'); - expect(repository.updateKeyMaterial).toHaveBeenCalledWith('user-1', 'key-1', { - keyPrefix: result.token.slice(0, 6), + expect(repository.updateKeyMaterial).toHaveBeenCalledWith('key-1', { + keyPrefix: result.token.slice(0, 10), keyHash: expect.any(String), expiresAt: '2026-12-31T00:00:00.000Z', }); expect(repository.create).not.toHaveBeenCalled(); expect(result.key).toMatchObject({ id: 'key-1', - name: 'Deploy', - keyPrefix: result.token.slice(0, 6), - expiresAt: '2026-12-31T00:00:00.000Z', + keyPrefix: result.token.slice(0, 10), lastUsedAt: null, revokedAt: null, }); }); - it('does not regenerate revoked keys', async () => { + it('rotates a project key and keeps its role', async () => { const repository = createRepositoryMock(); - repository.findById.mockImplementation(async () => ({ - id: 'key-1', - name: 'Deploy', - keyPrefix: 'gvs_old', - projectId: null, - expiresAt: null, - lastUsedAt: null, - revokedAt: '2026-08-18T00:10:00.000Z', - createdAt: '2026-08-18T00:00:00.000Z', - })); + repository.findByIdAny.mockImplementation(async () => + apiKey({ userId: null, projectId: 'project-1', roleId: 'role-1' }), + ); + + const service = new ApiKeysService(repository); + const result = await service.regenerateProjectApiKey('project-1', 'key-1'); + + expect(result.key.roleId).toBe('role-1'); + expect(repository.updateKeyMaterial).toHaveBeenCalledWith('key-1', expect.any(Object)); + }); + + it('does not rotate revoked keys', async () => { + const repository = createRepositoryMock(); + repository.findById.mockImplementation(async () => + apiKey({ revokedAt: '2026-08-18T00:10:00.000Z' }), + ); const service = new ApiKeysService(repository); diff --git a/src/modules/auth/application/apikeys.service.ts b/src/modules/auth/application/apikeys.service.ts index bbc2166..115d497 100644 --- a/src/modules/auth/application/apikeys.service.ts +++ b/src/modules/auth/application/apikeys.service.ts @@ -1,13 +1,34 @@ import crypto from 'crypto'; -import type { ApiKeyView } from '../domain/entities'; +import type { ApiKeyView, AuthenticatedApiKey, SessionRole } from '../domain/entities'; +import type { RoleDomain } from '../domain/role.domain'; import { ApiKeyRepository } from '../infrastructure/repositories/apikey.repository'; +const TOKEN_PREFIX = 'gvs_'; +const PREFIX_LENGTH = 10; + function hashApiKey(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } +function generateToken(): { token: string; keyPrefix: string; keyHash: string } { + const token = `${TOKEN_PREFIX}${crypto.randomBytes(24).toString('hex')}`; + return { token, keyPrefix: token.slice(0, PREFIX_LENGTH), keyHash: hashApiKey(token) }; +} + +type RoleLookup = { + findById(id: string): Promise; +}; + +type ProjectLookup = { + getProject(id: string): Promise<{ id: string; organization?: { id: string } | null } | null>; +}; + export class ApiKeysService { - constructor(private readonly apiKeyRepository: ApiKeyRepository) {} + constructor( + private readonly apiKeyRepository: ApiKeyRepository, + private readonly roleRepository?: RoleLookup, + private readonly projectLookup?: ProjectLookup, + ) {} async listActiveApiKeys(userId: string): Promise { return this.apiKeyRepository.listByUser(userId); @@ -18,77 +39,93 @@ export class ApiKeysService { } async validateApiKey(token: string): Promise { - if (!token.trim()) { - return false; - } - - const key = await this.apiKeyRepository.findValidByHash(hashApiKey(token)); - return key !== null; + return (await this.resolveApiKey(token)) !== null; } - // returns the full key (with projectId) for machine-to-machine endpoints that need to know - // which project issued the key, e.g. POST /api/code-report/analyse-result + /** Looks up a key by its clear-text token and records the usage. Revoked, rotated and expired keys resolve to `null`. */ async resolveApiKey(token: string): Promise { - if (!token.trim()) { + const normalized = token.trim(); + if (!normalized.startsWith(TOKEN_PREFIX)) { return null; } - const key = await this.apiKeyRepository.findValidByHash(hashApiKey(token)); + const key = await this.apiKeyRepository.findValidByHash(hashApiKey(normalized)); if (key) { await this.apiKeyRepository.touchLastUsed(key.id); } return key; } + /** Resolves the machine identity (project + role) behind a bearer token, for the API key auth path. */ + async authenticate(token: string): Promise { + const key = await this.resolveApiKey(token); + if (!key) { + return null; + } + + const role = key.roleId ? await this.roleRepository?.findById(key.roleId) : null; + + // a project key whose role is gone or was moved has no permissions left, so it must not authenticate + if (key.projectId && (!role || role.scope !== 'project' || role.projectId !== key.projectId)) { + return null; + } + + let organizationId: string | null = role?.project?.organization?.id ?? null; + if (key.projectId && !organizationId && this.projectLookup) { + const project = await this.projectLookup.getProject(key.projectId).catch(() => null); + organizationId = project?.organization?.id ?? null; + } + + return { + id: key.id, + name: key.name, + keyPrefix: key.keyPrefix, + projectId: key.projectId, + organizationId, + userId: key.userId, + role: role ? (role.toJson() as SessionRole) : null, + }; + } + async createApiKey( userId: string, name: string, expiresAt: string | null, - projectId: string | null = null, ): Promise<{ token: string; key: ApiKeyView }> { - const token = `gvs_${crypto.randomBytes(24).toString('hex')}`; - const id = crypto.randomUUID(); - const keyPrefix = token.slice(0, 6); - - await this.apiKeyRepository.create({ - id, + return this.persist({ userId, - projectId, + projectId: null, + roleId: null, + createdByUserId: userId, name, - keyPrefix, - keyHash: hashApiKey(token), expiresAt, }); - - return { - token, - key: { - id, - name, - keyPrefix, - projectId, - expiresAt, - lastUsedAt: null, - revokedAt: null, - createdAt: new Date().toISOString(), - }, - }; } - // convenience wrapper for project-scoped server access keys (used by CI/CD integrations) - async createProjectApiKey( - userId: string, - projectId: string, - name: string, - expiresAt: string | null, - ): Promise<{ token: string; key: ApiKeyView }> { - return this.createApiKey(userId, name, expiresAt, projectId); + /** Creates a project-scoped server key. `roleId` must be a role belonging to `projectId`. */ + async createProjectApiKey(input: { + projectId: string; + roleId: string; + name: string; + expiresAt: string | null; + createdByUserId: string; + }): Promise<{ token: string; key: ApiKeyView }> { + await this.assertRoleBelongsToProject(input.roleId, input.projectId); + + return this.persist({ + userId: null, + projectId: input.projectId, + roleId: input.roleId, + createdByUserId: input.createdByUserId, + name: input.name, + expiresAt: input.expiresAt, + }); } async revokeApiKey(userId: string, keyId: string): Promise { const existing = await this.apiKeyRepository.findById(userId, keyId); - if (!existing) { + if (!existing || existing.projectId) { throw new Error('API key not found'); } @@ -100,11 +137,7 @@ export class ApiKeysService { } async revokeProjectApiKey(projectId: string, keyId: string): Promise { - const existing = await this.apiKeyRepository.findByIdAny(keyId); - - if (!existing || existing.projectId !== projectId) { - throw new Error('API key not found'); - } + const existing = await this.findProjectKey(projectId, keyId); if (existing.revokedAt) { throw new Error('API key is already revoked'); @@ -119,7 +152,7 @@ export class ApiKeysService { ): Promise<{ token: string; key: ApiKeyView }> { const existing = await this.apiKeyRepository.findById(userId, keyId); - if (!existing) { + if (!existing || existing.projectId) { throw new Error('API key not found'); } @@ -127,27 +160,105 @@ export class ApiKeysService { throw new Error('API key is revoked'); } - const token = `gvs_${crypto.randomBytes(24).toString('hex')}`; - const keyPrefix = token.slice(0, 6); + return this.rotate(existing); + } + + async regenerateProjectApiKey( + projectId: string, + keyId: string, + ): Promise<{ token: string; key: ApiKeyView }> { + const existing = await this.findProjectKey(projectId, keyId); + + if (existing.revokedAt) { + throw new Error('API key is revoked'); + } + + return this.rotate(existing); + } + + private async findProjectKey(projectId: string, keyId: string): Promise { + const existing = await this.apiKeyRepository.findByIdAny(keyId); + + if (!existing || existing.projectId !== projectId) { + throw new Error('API key not found'); + } + + return existing; + } + + private async assertRoleBelongsToProject(roleId: string, projectId: string): Promise { + if (!roleId.trim()) { + throw new Error('A project role is required'); + } - await this.apiKeyRepository.updateKeyMaterial(userId, keyId, { + const role = await this.roleRepository?.findById(roleId); + if (!role || role.scope !== 'project' || role.projectId !== projectId) { + throw new Error('The selected role does not belong to this project'); + } + } + + private async persist(input: { + userId: string | null; + projectId: string | null; + roleId: string | null; + createdByUserId: string | null; + name: string; + expiresAt: string | null; + }): Promise<{ token: string; key: ApiKeyView }> { + const name = input.name.trim(); + if (!name) { + throw new Error('Key name is required'); + } + + if (Boolean(input.userId) === Boolean(input.projectId)) { + throw new Error('An API key must belong to either a user or a project'); + } + + const id = crypto.randomUUID(); + const { token, keyPrefix, keyHash } = generateToken(); + + await this.apiKeyRepository.create({ + id, + userId: input.userId, + projectId: input.projectId, + roleId: input.roleId, + createdByUserId: input.createdByUserId, + name, keyPrefix, - keyHash: hashApiKey(token), - expiresAt: existing.expiresAt, + keyHash, + expiresAt: input.expiresAt, }); return { token, key: { - id: keyId, - name: existing.name, + id, + name, keyPrefix, - projectId: existing.projectId, - expiresAt: existing.expiresAt, + userId: input.userId, + projectId: input.projectId, + roleId: input.roleId, + createdByUserId: input.createdByUserId, + expiresAt: input.expiresAt, lastUsedAt: null, revokedAt: null, createdAt: new Date().toISOString(), }, }; } + + private async rotate(existing: ApiKeyView): Promise<{ token: string; key: ApiKeyView }> { + const { token, keyPrefix, keyHash } = generateToken(); + + await this.apiKeyRepository.updateKeyMaterial(existing.id, { + keyPrefix, + keyHash, + expiresAt: existing.expiresAt, + }); + + return { + token, + key: { ...existing, keyPrefix, lastUsedAt: null, revokedAt: null }, + }; + } } diff --git a/src/modules/auth/application/auth.service.test.ts b/src/modules/auth/application/auth.service.test.ts index e47d2a0..55f1ef4 100644 --- a/src/modules/auth/application/auth.service.test.ts +++ b/src/modules/auth/application/auth.service.test.ts @@ -15,7 +15,12 @@ function role(input: { id: string; slug: string; permissions?: string[] }) { }); } -function user(input: { id: string; username: string; role: RoleDomain | null; email?: string | null }) { +function user(input: { + id: string; + username: string; + role: RoleDomain | null; + email?: string | null; +}) { return new UserDomain({ id: input.id, username: input.username, diff --git a/src/modules/auth/application/cancan.service.test.ts b/src/modules/auth/application/cancan.service.test.ts index 073c349..835e098 100644 --- a/src/modules/auth/application/cancan.service.test.ts +++ b/src/modules/auth/application/cancan.service.test.ts @@ -106,6 +106,47 @@ describe('CanCanService', () => { expect(CanCanService.hasPermission(['vault:all'], 'openreport:read')).toBe(false); expect(CanCanService.hasPermission(['project:vault:read'], 'vault:read')).toBe(true); expect(CanCanService.hasPermission(['project:vault:all'], 'vault:delete')).toBe(true); + expect( + CanCanService.hasPermission(['project:server-keys:all'], 'project:server-keys:read'), + ).toBe(true); + expect(CanCanService.hasPermission(['project:server-keys:read'], 'project:roles:read')).toBe( + false, + ); + }); + + it('authorizes an api key only inside its own project', () => { + const apiKey = { + projectId: 'kettu', + role: role({ + id: 'role-1', + slug: 'project-developer', + scope: 'project', + permissions: ['project:codereport:reports:create'], + }), + }; + + expect( + service.canApiKey(apiKey, 'project:codereport:reports:create', { + scope: 'project', + projectId: 'kettu', + }), + ).toBe(true); + expect( + service.canApiKey(apiKey, 'project:codereport:reports:create', { + scope: 'project', + projectId: 'other', + }), + ).toBe(false); + expect(service.canApiKey(apiKey, 'cluster:settings:read', { scope: 'cluster' })).toBe(false); + expect( + service.canApiKey(apiKey, 'project:roles:delete', { scope: 'project', projectId: 'kettu' }), + ).toBe(false); + expect( + service.canApiKey(null, 'project:codereport:reports:create', { + scope: 'project', + projectId: 'kettu', + }), + ).toBe(false); }); it('allows a cluster admin without organization or project access rows', async () => { diff --git a/src/modules/auth/application/cancan.service.ts b/src/modules/auth/application/cancan.service.ts index 771ff6d..80a5d6c 100644 --- a/src/modules/auth/application/cancan.service.ts +++ b/src/modules/auth/application/cancan.service.ts @@ -70,6 +70,24 @@ export class CanCanService { return this.canSessionUser(user, 'stateiac:read', { scope: 'organization', organizationId }); } + /** + * Authorizes a machine identity. A project-scoped key can only ever act inside its own project, + * so any cluster- or cross-project context is denied regardless of the role permissions. + */ + canApiKey( + apiKey: { projectId: string | null; role: PermissionRole } | null | undefined, + permission: PermissionGrant, + context: CanCanContext, + ): boolean { + if (!apiKey) return false; + + if (apiKey.projectId) { + if (context.scope !== 'project' || context.projectId !== apiKey.projectId) return false; + } + + return this.roleCan(apiKey.role ?? null, permission); + } + async canManageProject( user: PermissionAwareUser, projectId: string, @@ -182,7 +200,10 @@ export class CanCanService { grant === permission || grant === `${section}:all` || grant.endsWith(`:${permission}`) || - grant.endsWith(`:${section}:all`), + grant.endsWith(`:${section}:all`) || + // a `:all` grant covers every action on that resource, e.g. + // `project:server-keys:all` covers `project:server-keys:read` + (grant.endsWith(':all') && permission.startsWith(grant.slice(0, -3))), ); } diff --git a/src/modules/auth/application/user-access.service.test.ts b/src/modules/auth/application/user-access.service.test.ts index bf2cf4a..99cbf48 100644 --- a/src/modules/auth/application/user-access.service.test.ts +++ b/src/modules/auth/application/user-access.service.test.ts @@ -478,7 +478,12 @@ describe('UserAccessService', () => { it('removes all user access and the user when removed from an organization', async () => { userRepository.rows.push(user({ id: 'jose-id', username: 'jose', role: null })); roleRepository.rows.push( - role({ id: 'org-admin-id', slug: 'org-admin', scope: 'organization', organizationId: 'gitops' }), + role({ + id: 'org-admin-id', + slug: 'org-admin', + scope: 'organization', + organizationId: 'gitops', + }), role({ id: 'project-admin-id', slug: 'project-admin', scope: 'project', projectId: 'kettu' }), role({ id: 'project-developer-id', slug: 'developer', scope: 'project', projectId: 'other' }), ); @@ -517,7 +522,12 @@ describe('UserAccessService', () => { it('removes all user access and the user when removed from cluster settings', async () => { userRepository.rows.push(user({ id: 'jose-id', username: 'jose', role: null })); roleRepository.rows.push( - role({ id: 'org-admin-id', slug: 'org-admin', scope: 'organization', organizationId: 'gitops' }), + role({ + id: 'org-admin-id', + slug: 'org-admin', + scope: 'organization', + organizationId: 'gitops', + }), role({ id: 'project-admin-id', slug: 'project-admin', scope: 'project', projectId: 'kettu' }), ); await userAccessRepository.create({ diff --git a/src/modules/auth/application/user-access.service.ts b/src/modules/auth/application/user-access.service.ts index b892d75..bb5256a 100644 --- a/src/modules/auth/application/user-access.service.ts +++ b/src/modules/auth/application/user-access.service.ts @@ -54,13 +54,7 @@ export class UserAccessService { private readonly roleRepository: Pick, private readonly userAccessRepository: Pick< UserAccessRepository, - | 'findByScope' - | 'findByUserId' - | 'findOne' - | 'findById' - | 'create' - | 'update' - | 'deleteById' + 'findByScope' | 'findByUserId' | 'findOne' | 'findById' | 'create' | 'update' | 'deleteById' >, private readonly passwordService: Pick, private readonly invitationNotifier: InvitationNotifierPort, diff --git a/src/modules/auth/domain/entities.ts b/src/modules/auth/domain/entities.ts index 547c208..93971a7 100644 --- a/src/modules/auth/domain/entities.ts +++ b/src/modules/auth/domain/entities.ts @@ -67,9 +67,23 @@ export type ApiKeyView = { id: string; name: string; keyPrefix: string; + userId: string | null; projectId: string | null; + roleId: string | null; + createdByUserId: string | null; expiresAt: string | null; lastUsedAt: string | null; revokedAt: string | null; createdAt: string; }; + +/** Identity resolved from an `Authorization: Bearer` API key, the machine-to-machine counterpart of `AuthenticatedUser`. */ +export type AuthenticatedApiKey = { + id: string; + name: string; + keyPrefix: string; + projectId: string | null; + organizationId: string | null; + userId: string | null; + role: SessionRole | null; +}; diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index 31a30cf..716b0ec 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -23,7 +23,7 @@ const invitationNotifier = new InvitationNotifier(); export const passwordService = new PasswordService(); const sessionService = new SessionService(passwordService); -export const apiKeysService = new ApiKeysService(apiKeyRepository); +export const apiKeysService = new ApiKeysService(apiKeyRepository, roleRepository, projectService); export const authService = new AuthService( userRepository, diff --git a/src/modules/auth/infrastructure/repositories/apikey.repository.ts b/src/modules/auth/infrastructure/repositories/apikey.repository.ts index eb8c4f0..d472d61 100644 --- a/src/modules/auth/infrastructure/repositories/apikey.repository.ts +++ b/src/modules/auth/infrastructure/repositories/apikey.repository.ts @@ -4,8 +4,10 @@ import type { ApiKeyView } from '../../domain/entities'; type ApiKeyRow = { id: string; - userId: string; + userId: string | null; projectId: string | null; + roleId: string | null; + createdByUserId: string | null; name: string; keyPrefix: string; keyHash: string; @@ -23,7 +25,9 @@ export class ApiKeyRepository extends Repository { .where({ userId }) .orderBy('createdAt', 'desc'); - return (result.rows as ApiKeyRow[]).map((row) => this.toJSON(row)); + return (result.rows as ApiKeyRow[]) + .filter((row) => !row.projectId) + .map((row) => this.toJSON(row)); } async findById(userId: string, keyId: string): Promise { @@ -75,8 +79,10 @@ export class ApiKeyRepository extends Repository { async create(input: { id: string; - userId: string; + userId: string | null; projectId: string | null; + roleId: string | null; + createdByUserId: string | null; name: string; keyPrefix: string; keyHash: string; @@ -86,6 +92,8 @@ export class ApiKeyRepository extends Repository { id: input.id, userId: input.userId, projectId: input.projectId, + roleId: input.roleId, + createdByUserId: input.createdByUserId, name: input.name, keyPrefix: input.keyPrefix, keyHash: input.keyHash, @@ -118,7 +126,6 @@ export class ApiKeyRepository extends Repository { } async updateKeyMaterial( - userId: string, keyId: string, input: { keyPrefix: string; @@ -135,7 +142,7 @@ export class ApiKeyRepository extends Repository { revokedAt: null, lastUsedAt: null, }) - .where({ id: keyId, userId }); + .where({ id: keyId }); } protected override toJSON(row: ApiKeyRow): ApiKeyView { @@ -143,7 +150,10 @@ export class ApiKeyRepository extends Repository { id: row.id, name: row.name, keyPrefix: row.keyPrefix.slice(0, 6), + userId: row.userId ?? null, projectId: row.projectId ?? null, + roleId: row.roleId ?? null, + createdByUserId: row.createdByUserId ?? null, expiresAt: row.expiresAt, lastUsedAt: row.lastUsedAt, revokedAt: row.revokedAt, diff --git a/src/routes/api/code-report/scan/+server.ts b/src/routes/api/code-report/scan/+server.ts index 2b24ef6..9fc6e36 100644 --- a/src/routes/api/code-report/scan/+server.ts +++ b/src/routes/api/code-report/scan/+server.ts @@ -1,6 +1,7 @@ import { json } from '@sveltejs/kit'; import { codeReportService, codeReportAnalysisService } from '$modules/code-report'; import { projectService } from '$modules/projects'; +import { cancanService } from '$modules/auth'; type ProjectSettingsTool = { id: string; @@ -78,23 +79,14 @@ function normalizeGitInfo(gitInfo: AnalyseResultBody['gitInfo']) { }; } - // single machine-to-machine endpoint for CI/CD tools: authenticates via a project-scoped // server access key (Authorization: Bearer gvs_...), not a browser session -export async function POST({ request }) { - - // console.log('REQUEST BODY:', await request.clone().text()); // Log the request body for debugging - // const authHeader = request.headers.get('authorization') || ''; - // const token = authHeader.replace(/^Bearer\s+/i, '').trim(); - - // if (!token) { - // return json({ error: 'Missing API key' }, { status: 401 }); - // } +export async function POST({ request, locals }) { + const apiKey = locals.apiKey; - // const apiKey = await apiKeysService.resolveApiKey(token); - // if (!apiKey || !apiKey.projectId) { - // return json({ error: 'Invalid or unscoped API key' }, { status: 401 }); - // } + if (!apiKey?.projectId) { + return json({ error: 'A project-scoped API key is required' }, { status: 401 }); + } const body = (await request.json()) as AnalyseResultBody; @@ -115,7 +107,7 @@ export async function POST({ request }) { let analysis; let tools: string[] = []; let activeSettingsTools: ProjectSettingsTool[] = []; - if(['start', 'in_progress'].includes(status)) { + if (['start', 'in_progress'].includes(status)) { //use service and project to find or create the service if (!body.service) { return json({ error: 'service is required' }, { status: 400 }); @@ -129,6 +121,19 @@ export async function POST({ request }) { return json({ error: 'project not found' }, { status: 404 }); } + if (project.id !== apiKey.projectId) { + return json({ error: 'Forbidden' }, { status: 403 }); + } + + if ( + !cancanService.canApiKey(apiKey, 'project:codereport:reports:create', { + scope: 'project', + projectId: project.id, + }) + ) { + return json({ error: 'Forbidden' }, { status: 403 }); + } + const codeReportSettings = project.settings?.['code-report'] || {}; const persistedTools = Array.isArray(codeReportSettings.tools) ? (codeReportSettings.tools as ProjectSettingsTool[]) @@ -156,36 +161,38 @@ export async function POST({ request }) { tools = [...new Set(configuredScanners)]; - let serviceCodeReport = await codeReportService.getByProjectAndSlug(body.project, body.service).catch(async (err) => { - console.log('err', err) - return await codeReportService.createService({ - project: body.project, - name: body.service, + let serviceCodeReport = await codeReportService + .getByProjectAndSlug(body.project, body.service) + .catch(async (err) => { + console.log('err', err); + return await codeReportService.createService({ + project: body.project, + name: body.service, + }); }); - }); if (tools.length === 0) { tools = serviceCodeReport?.tools || []; } console.log('✅ Service found or created:', serviceCodeReport); - if(status === 'start') { + if (status === 'start') { message = 'Scan started'; - } else if(status === 'in_progress') { + } else if (status === 'in_progress') { console.log('Starting analysis for service:', serviceCodeReport?.id, 'with tool:', body.tool); analysis = await codeReportAnalysisService.startAnalysis({ serviceId: serviceCodeReport?.id || '', tool: body.tool, gitInfo: normalizeGitInfo(body.gitInfo), }); - console.log('ANALYSIS', analysis) + console.log('ANALYSIS', analysis); message = `Scan in progress with tool ${body.tool}.`; } } else { // completed or failed // use analysisId to find - if(!body.analysisId) { + if (!body.analysisId) { return json({ error: 'analysisId is required for failed status' }, { status: 400 }); } - if(status === 'failed') { + if (status === 'failed') { await codeReportAnalysisService.failAnalysis(body.analysisId, { error: body.error || 'Unknown error', gitInfo: normalizeGitInfo(body.gitInfo), @@ -205,7 +212,7 @@ export async function POST({ request }) { message: message, analysis: { status, - id: analysis?.id + id: analysis?.id, }, tools, activeSettingsTools, diff --git a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts index 8f56b7d..bbe4b0c 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts +++ b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.server.ts @@ -1,11 +1,38 @@ import { error, fail } from '@sveltejs/kit'; -import { apiKeysService, cancanService } from '$modules/auth'; +import type { PermissionGrant } from '$lib/permissions'; +import { apiKeysService, cancanService, roleService } from '$modules/auth'; import { projectService } from '$modules/projects'; +async function authorize( + user: Parameters[0], + projectSlug: string, + permission: PermissionGrant, +) { + const project = await projectService.getProjectBySlug(projectSlug); + const allowed = await cancanService.canSessionUser(user, permission, { + scope: 'project', + projectId: project.id, + organizationId: project.organization?.id, + }); + return { project, allowed }; +} + +function expiresAtFromDays(expiresInDays: string): string | null { + const days = Number(expiresInDays); + if (!expiresInDays || !Number.isFinite(days) || days <= 0) { + return null; + } + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); +} + +function errorResponse(err: unknown) { + return fail(400, { error: err instanceof Error ? err.message : 'Server key action failed.' }); +} + export async function load({ parent, locals }) { const { project } = await parent(); - const canRead = await cancanService.canSessionUser(locals.user, 'openreport:read', { + const canRead = await cancanService.canSessionUser(locals.user, 'project:server-keys:read', { scope: 'project', projectId: project.id, organizationId: project.organization?.id, @@ -15,70 +42,79 @@ export async function load({ parent, locals }) { throw error(403, 'Forbidden'); } - const apiKeys = await apiKeysService.listActiveApiKeysByProject(project.id); + const [apiKeys, roles] = await Promise.all([ + apiKeysService.listActiveApiKeysByProject(project.id), + roleService.listRoles('project', project.id), + ]); - return { apiKeys }; + return { + apiKeys, + roles: roles.map((role) => ({ id: role.id, name: role.name, slug: role.slug })), + }; } export const actions = { create: async ({ request, params, locals }) => { - const project = await projectService.getProjectBySlug(params.slug); - - const canCreate = await cancanService.canSessionUser(locals.user, 'openreport:create', { - scope: 'project', - projectId: project.id, - organizationId: project.organization?.id, - }); - - if (!canCreate) { - return fail(403, { error: 'Forbidden' }); - } + const { project, allowed } = await authorize( + locals.user, + params.slug, + 'project:server-keys:create', + ); + if (!allowed) return fail(403, { error: 'Forbidden' }); const formData = await request.formData(); - const name = String(formData.get('name') || '').trim(); - const expiresInDays = String(formData.get('expiresInDays') || '').trim(); - if (!name) { - return fail(400, { error: 'Key name is required.' }); + try { + const { token } = await apiKeysService.createProjectApiKey({ + projectId: project.id, + roleId: String(formData.get('roleId') || ''), + name: String(formData.get('name') || ''), + expiresAt: expiresAtFromDays(String(formData.get('expiresInDays') || '').trim()), + createdByUserId: locals.user!.id, + }); + + return { success: true, createdKey: token }; + } catch (err) { + return errorResponse(err); } + }, - const expiresAt = expiresInDays - ? new Date(Date.now() + Number(expiresInDays) * 24 * 60 * 60 * 1000).toISOString() - : null; - - const { token } = await apiKeysService.createProjectApiKey( - locals.user!.id, - project.id, - name, - expiresAt, + rotate: async ({ request, params, locals }) => { + const { project, allowed } = await authorize( + locals.user, + params.slug, + 'project:server-keys:update', ); + if (!allowed) return fail(403, { error: 'Forbidden' }); - return { success: true, createdKey: token }; + const formData = await request.formData(); + + try { + const { token } = await apiKeysService.regenerateProjectApiKey( + project.id, + String(formData.get('keyId') || ''), + ); + return { success: true, createdKey: token }; + } catch (err) { + return errorResponse(err); + } }, revoke: async ({ request, params, locals }) => { - const project = await projectService.getProjectBySlug(params.slug); - - const canDelete = await cancanService.canSessionUser(locals.user, 'openreport:delete', { - scope: 'project', - projectId: project.id, - organizationId: project.organization?.id, - }); - - if (!canDelete) { - return fail(403, { error: 'Forbidden' }); - } + const { project, allowed } = await authorize( + locals.user, + params.slug, + 'project:server-keys:delete', + ); + if (!allowed) return fail(403, { error: 'Forbidden' }); const formData = await request.formData(); - const keyId = String(formData.get('keyId') || ''); try { - await apiKeysService.revokeProjectApiKey(project.id, keyId); + await apiKeysService.revokeProjectApiKey(project.id, String(formData.get('keyId') || '')); + return { success: true }; } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to revoke key'; - return fail(400, { error: message }); + return errorResponse(err); } - - return { success: true }; }, }; diff --git a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte index cee512d..85421b0 100644 --- a/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte +++ b/src/routes/org/[org]/projects/[slug]/settings/server-access-keys/+page.svelte @@ -1,28 +1,35 @@