diff --git a/.gitignore b/.gitignore index 9a5aced..0dedcd9 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,10 @@ web_modules/ .env.* !.env.example +# Test databases +test/*.sqlite3 +test/*.sqlite3-* + # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache diff --git a/env.example b/env.example index c228cff..d616010 100644 --- a/env.example +++ b/env.example @@ -16,12 +16,15 @@ AUTH_DIR=/etc/korp-auth # ============================================================================ # Path to JWT private key (PEM format) -# Generate with: openssl genrsa -out private_key.pem 2048 JWT_PRIVATE_KEY_PATH=/etc/korp-auth/private_key.pem # API key for Mink service integration (resource deletion endpoint) MINK_API_KEY=your-api-key-here +# API key for admin endpoints (entitlement and grant management) +# Generate a strong random key, e.g.: openssl rand -hex 32 +ADMIN_API_KEY=your-admin-api-key-here + # ============================================================================ # Database Configuration # ============================================================================ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f1c18ca --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1900 @@ +{ + "name": "korp-auth", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "korp-auth", + "version": "1.0.0", + "dependencies": { + "better-sqlite3": "^9.2.2", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "debug": "^4.4.3", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2" + }, + "devDependencies": { + "nodemon": "^3.0.1" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", + "integrity": "sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", + "integrity": "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-static/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json index 6f92427..c075abd 100644 --- a/package.json +++ b/package.json @@ -5,15 +5,17 @@ "main": "src/korp-auth.js", "scripts": { "start": "node src/korp-auth.js", - "dev": "nodemon src/korp-auth.js" + "dev": "nodemon src/korp-auth.js", + "test": "node test/db.test.js" }, "dependencies": { - "express": "^4.18.2", - "jsonwebtoken": "^9.0.2", - "cors": "^2.8.5", + "better-sqlite3": "^9.2.2", "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "debug": "^4.4.3", "dotenv": "^16.3.1", - "better-sqlite3": "^9.2.2" + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/src/config.js b/src/config.js index 0e1bbac..888f599 100644 --- a/src/config.js +++ b/src/config.js @@ -12,6 +12,7 @@ const config = { jwtPrivateKeyPath: process.env.JWT_PRIVATE_KEY_PATH || path.join(process.env.AUTH_DIR || path.join(__dirname, '..', 'data'), 'private_key.pem'), minkApiKey: process.env.MINK_API_KEY || '', + adminApiKey: process.env.ADMIN_API_KEY || '', dbPath: process.env.DB_PATH || path.join(process.env.AUTH_DIR || path.join(__dirname, '..', 'data'), 'resources.sqlite3'), @@ -41,7 +42,7 @@ const config = { // Development mode only configs authCookieName: process.env.AUTH_COOKIE_NAME || 'kp-future-auth-token', fallbackRedirectUri: process.env.FALLBACK_REDIRECT_URI || 'https://www.kielipankki.fi', - demoUsers: process.env.DEMO_USERS || {}; + demoUsers: process.env.DEMO_USERS ? JSON.parse(process.env.DEMO_USERS) : {} }; module.exports = config; diff --git a/src/db.js b/src/db.js index 04d83ec..3972bd6 100644 --- a/src/db.js +++ b/src/db.js @@ -30,15 +30,33 @@ function create_db_if_missing() { // Create database and tables const db = new Database(DB_PATH); - + try { - // Create USERS table + // Enable foreign key constraints + db.exec('PRAGMA foreign_keys = ON'); + + // Create USERS table (production: OIDC sub, development: username) db.exec(` CREATE TABLE USERS ( - username TEXT PRIMARY KEY, - password TEXT NOT NULL + user_id INTEGER PRIMARY KEY AUTOINCREMENT, + identifier TEXT NOT NULL UNIQUE, + password TEXT, -- this is only for the dev environment, no real passwords stored + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + db.exec('CREATE INDEX idx_users_identifier ON USERS(identifier)'); + + // Create ENTITLEMENTS table (LBR URNs from eduPersonEntitlement claims) + db.exec(` + CREATE TABLE ENTITLEMENTS ( + entitlement_id INTEGER PRIMARY KEY AUTOINCREMENT, + identifier TEXT NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); + db.exec('CREATE INDEX idx_entitlements_identifier ON ENTITLEMENTS(identifier)'); // Create RESOURCES table db.exec(` @@ -48,72 +66,104 @@ function create_db_if_missing() { ) `); - // Create GRANTS table with foreign key constraints + // Create GRANTS table with polymorphic foreign keys db.exec(` CREATE TABLE GRANTS ( id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, + user_id INTEGER, + entitlement_id INTEGER, resource_name TEXT NOT NULL, permission_level INTEGER NOT NULL CHECK (permission_level IN (1, 2, 3)), - FOREIGN KEY (username) REFERENCES USERS(username) ON DELETE CASCADE, + granted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES USERS(user_id) ON DELETE CASCADE, + FOREIGN KEY (entitlement_id) REFERENCES ENTITLEMENTS(entitlement_id) ON DELETE CASCADE, FOREIGN KEY (resource_name) REFERENCES RESOURCES(resource_name) ON DELETE CASCADE, - UNIQUE(username, resource_name) + CHECK ((user_id IS NOT NULL AND entitlement_id IS NULL) OR + (user_id IS NULL AND entitlement_id IS NOT NULL)) ) `); + db.exec('CREATE INDEX idx_grants_user ON GRANTS(user_id)'); + db.exec('CREATE INDEX idx_grants_entitlement ON GRANTS(entitlement_id)'); + db.exec('CREATE INDEX idx_grants_resource ON GRANTS(resource_name)'); - // Enable foreign key constraints - db.exec('PRAGMA foreign_keys = ON'); + // Unique constraints: this way one (id, resource) pair should have only one access level + db.exec('CREATE UNIQUE INDEX idx_grants_user_resource ON GRANTS(user_id, resource_name)'); + db.exec('CREATE UNIQUE INDEX idx_grants_entitlement_resource ON GRANTS(entitlement_id, resource_name)'); + // Insert demo users for development mode if (demo_users) { - const stmt = db.prepare('INSERT INTO USERS (username, password) VALUES (?, ?)'); + const stmt = db.prepare('INSERT INTO USERS (identifier, password) VALUES (?, ?)'); for (const demo_username in demo_users) { stmt.run(demo_username, demo_users[demo_username].password); } } - + } finally { db.close(); } } -function get_user_password(username) { +function get_user_password(identifier) { const db = new Database(DB_PATH); try { const stmt = db.prepare(` - SELECT password FROM USERS WHERE username = ?`); - return stmt.get(username).password; + SELECT password FROM USERS WHERE identifier = ?`); + const result = stmt.get(identifier); + return result ? result.password : null; } finally { db.close() } } -function user_exists(username) { +function user_exists(identifier) { const db = new Database(DB_PATH); - + try { - const stmt = db.prepare('SELECT 1 FROM USERS WHERE username = ? LIMIT 1'); - const result = stmt.get(username); + const stmt = db.prepare('SELECT 1 FROM USERS WHERE identifier = ? LIMIT 1'); + const result = stmt.get(identifier); return result !== undefined; } finally { db.close(); } } -function get_user_scope(username) { +function get_user_scope(userIdentifier, entitlements = []) { const db = new Database(DB_PATH); - + try { - const stmt = db.prepare(` - SELECT r.resource_name, r.type, g.permission_level + // Build query to get grants from both user and entitlements + let query = ` + SELECT r.resource_name, r.type, MAX(g.permission_level) as permission_level FROM GRANTS g JOIN RESOURCES r ON g.resource_name = r.resource_name - WHERE g.username = ? - `); - - const rows = stmt.all(username); - + WHERE ( + g.user_id = (SELECT user_id FROM USERS WHERE identifier = ?) + `; + + const params = [userIdentifier]; + + // Add entitlement lookups if provided + if (entitlements.length > 0) { + const entitlementPlaceholders = entitlements.map(() => '?').join(','); + query += ` + OR g.entitlement_id IN ( + SELECT entitlement_id FROM ENTITLEMENTS WHERE identifier IN (${entitlementPlaceholders}) + ) + `; + params.push(...entitlements); + } + + query += ` + ) + GROUP BY r.resource_name, r.type + `; + + const stmt = db.prepare(query); + const rows = stmt.all(...params); + + // Transform to scope object grouped by resource type const scope = {}; - + for (const row of rows) { const scopeKey = row.type === 'corpus' ? 'corpora' : row.type; if (!scope[scopeKey]) { @@ -121,28 +171,29 @@ function get_user_scope(username) { } scope[scopeKey][row.resource_name] = row.permission_level; } - + return scope; - + } finally { db.close(); } } -function get_user_scope_all_resources(username) { +function get_user_scope_all_resources(userIdentifier) { const db = new Database(DB_PATH); - + try { const stmt = db.prepare(` SELECT r.resource_name, r.type, COALESCE(g.permission_level, 0) as permission_level FROM RESOURCES r - LEFT JOIN GRANTS g ON r.resource_name = g.resource_name AND g.username = ? + LEFT JOIN GRANTS g ON r.resource_name = g.resource_name + AND g.user_id = (SELECT user_id FROM USERS WHERE identifier = ?) `); - - const rows = stmt.all(username); - + + const rows = stmt.all(userIdentifier); + const scope = {}; - + for (const row of rows) { const scopeKey = row.type === 'corpus' ? 'corpora' : row.type; if (!scope[scopeKey]) { @@ -150,9 +201,9 @@ function get_user_scope_all_resources(username) { } scope[scopeKey][row.resource_name] = row.permission_level; } - + return { scope }; - + } finally { db.close(); } @@ -190,7 +241,7 @@ function create_resource(resource_name, resource_type) { function delete_resource(resource_name) { const db = new Database(DB_PATH); - + try { const stmt = db.prepare('DELETE FROM RESOURCES WHERE resource_name = ?'); stmt.run(resource_name); @@ -199,76 +250,296 @@ function delete_resource(resource_name) { } } -function add_user(username) { +function resource_exists(resource_name) { const db = new Database(DB_PATH); - + + try { + const stmt = db.prepare('SELECT 1 FROM RESOURCES WHERE resource_name = ? LIMIT 1'); + const result = stmt.get(resource_name); + return result !== undefined; + } finally { + db.close(); + } +} + +function list_resources() { + const db = new Database(DB_PATH); + + try { + const stmt = db.prepare(` + SELECT + r.resource_name, + r.type, + COUNT(g.id) as grant_count + FROM RESOURCES r + LEFT JOIN GRANTS g ON r.resource_name = g.resource_name + GROUP BY r.resource_name + ORDER BY r.type, r.resource_name + `); + return stmt.all(); + } finally { + db.close(); + } +} + +function add_user(identifier) { + const db = new Database(DB_PATH); + try { - const stmt = db.prepare('INSERT INTO USERS (username) VALUES (?)'); - stmt.run(username); + const stmt = db.prepare('INSERT INTO USERS (identifier) VALUES (?)'); + stmt.run(identifier); } finally { db.close(); } } -function delete_user(username) { +function delete_user(identifier) { const db = new Database(DB_PATH); - + try { - const stmt = db.prepare('DELETE FROM USERS WHERE username = ?'); - stmt.run(username); + const stmt = db.prepare('DELETE FROM USERS WHERE identifier = ?'); + stmt.run(identifier); // Grants will be automatically deleted due to CASCADE constraint } finally { db.close(); } } -function set_grant(user, resource_name, level) { +/** + * Set grant for either a user or an entitlement + * @param {object} options - {userIdentifier, entitlementIdentifier, resourceName, level} + */ +function set_grant({ userIdentifier, entitlementIdentifier, resourceName, level }) { if (![1, 2, 3].includes(level)) { throw new Error('Invalid permission level. Must be 1 (READ), 2 (WRITE), or 3 (ADMIN)'); } + if (!userIdentifier && !entitlementIdentifier) { + throw new Error('Must specify either userIdentifier or entitlementIdentifier'); + } + + if (userIdentifier && entitlementIdentifier) { + throw new Error('Cannot specify both userIdentifier and entitlementIdentifier'); + } + const db = new Database(DB_PATH); - + try { - const stmt = db.prepare(` - INSERT INTO GRANTS (username, resource_name, permission_level) - VALUES (?, ?, ?) - ON CONFLICT(username, resource_name) - DO UPDATE SET permission_level = excluded.permission_level - `); - stmt.run(user, resource_name, level); + if (userIdentifier) { + const stmt = db.prepare(` + INSERT INTO GRANTS (user_id, resource_name, permission_level) + VALUES ( + (SELECT user_id FROM USERS WHERE identifier = ?), + ?, ? + ) + ON CONFLICT(user_id, resource_name) + DO UPDATE SET permission_level = excluded.permission_level + WHERE user_id IS NOT NULL + `); + stmt.run(userIdentifier, resourceName, level); + } else { + const stmt = db.prepare(` + INSERT INTO GRANTS (entitlement_id, resource_name, permission_level) + VALUES ( + (SELECT entitlement_id FROM ENTITLEMENTS WHERE identifier = ?), + ?, ? + ) + ON CONFLICT(entitlement_id, resource_name) + DO UPDATE SET permission_level = excluded.permission_level + WHERE entitlement_id IS NOT NULL + `); + stmt.run(entitlementIdentifier, resourceName, level); + } + } finally { + db.close(); + } +} + +/** + * Remove grant for either a user or an entitlement + */ +function remove_grant({ userIdentifier, entitlementIdentifier, resourceName }) { + if (!userIdentifier && !entitlementIdentifier) { + throw new Error('Must specify either userIdentifier or entitlementIdentifier'); + } + + const db = new Database(DB_PATH); + + try { + if (userIdentifier) { + const stmt = db.prepare(` + DELETE FROM GRANTS + WHERE user_id = (SELECT user_id FROM USERS WHERE identifier = ?) + AND resource_name = ? + `); + stmt.run(userIdentifier, resourceName); + } else { + const stmt = db.prepare(` + DELETE FROM GRANTS + WHERE entitlement_id = (SELECT entitlement_id FROM ENTITLEMENTS WHERE identifier = ?) + AND resource_name = ? + `); + stmt.run(entitlementIdentifier, resourceName); + } } finally { db.close(); } } -function remove_grant(user, resource_name) { +/** + * Remove all grants for an entitlement + */ +function remove_all_grants_for_entitlement(entitlementIdentifier) { const db = new Database(DB_PATH); - + try { - const stmt = db.prepare('DELETE FROM GRANTS WHERE username = ? AND resource_name = ?'); - stmt.run(user, resource_name); + const stmt = db.prepare(` + DELETE FROM GRANTS + WHERE entitlement_id = (SELECT entitlement_id FROM ENTITLEMENTS WHERE identifier = ?) + `); + stmt.run(entitlementIdentifier); } finally { db.close(); } } -function user_is_resource_admin(username, resourcename) { +function user_is_resource_admin(userIdentifier, resourcename) { const db = new Database(DB_PATH); - + try { const stmt = db.prepare(` - SELECT 1 FROM GRANTS - WHERE username = ? AND resource_name = ? AND permission_level = 3 + SELECT 1 FROM GRANTS g + JOIN USERS u ON g.user_id = u.user_id + WHERE u.identifier = ? AND g.resource_name = ? AND g.permission_level = 3 LIMIT 1 `); - const result = stmt.get(username, resourcename); + const result = stmt.get(userIdentifier, resourcename); return result !== undefined; } finally { db.close(); } } +// ============================================================================ +// User Management +// ============================================================================ + +function ensure_user(identifier) { + const db = new Database(DB_PATH); + + try { + let stmt = db.prepare('SELECT user_id FROM USERS WHERE identifier = ?'); + let result = stmt.get(identifier); + + if (result) { + return result.user_id; + } + + stmt = db.prepare('INSERT INTO USERS (identifier) VALUES (?)'); + const info = stmt.run(identifier); + return info.lastInsertRowid; + + } finally { + db.close(); + } +} + +// ============================================================================ +// Entitlement Management +// ============================================================================ + +function create_entitlement(identifier, description = null) { + const db = new Database(DB_PATH); + + try { + const stmt = db.prepare('INSERT INTO ENTITLEMENTS (identifier, description) VALUES (?, ?)'); + const info = stmt.run(identifier, description); + return info.lastInsertRowid; + } finally { + db.close(); + } +} + +function entitlement_exists(identifier) { + const db = new Database(DB_PATH); + + try { + const stmt = db.prepare('SELECT 1 FROM ENTITLEMENTS WHERE identifier = ? LIMIT 1'); + const result = stmt.get(identifier); + return result !== undefined; + } finally { + db.close(); + } +} + +function list_entitlements() { + const db = new Database(DB_PATH); + + try { + const stmt = db.prepare(` + SELECT + e.entitlement_id, + e.identifier, + e.description, + e.created_at, + e.updated_at, + COUNT(g.id) as grant_count + FROM ENTITLEMENTS e + LEFT JOIN GRANTS g ON e.entitlement_id = g.entitlement_id + GROUP BY e.entitlement_id + ORDER BY e.created_at DESC + `); + return stmt.all(); + } finally { + db.close(); + } +} + +function update_entitlement_description(identifier, description) { + const db = new Database(DB_PATH); + + try { + const stmt = db.prepare(` + UPDATE ENTITLEMENTS + SET description = ?, updated_at = CURRENT_TIMESTAMP + WHERE identifier = ? + `); + stmt.run(description, identifier); + } finally { + db.close(); + } +} + +function delete_entitlement(identifier) { + const db = new Database(DB_PATH); + + try { + const stmt = db.prepare('DELETE FROM ENTITLEMENTS WHERE identifier = ?'); + const result = stmt.run(identifier); + return result.changes > 0; + } finally { + db.close(); + } +} + +function get_grants_for_entitlement(identifier) { + const db = new Database(DB_PATH); + + try { + const stmt = db.prepare(` + SELECT g.resource_name, g.permission_level, r.type, g.granted_at + FROM GRANTS g + JOIN ENTITLEMENTS e ON g.entitlement_id = e.entitlement_id + JOIN RESOURCES r ON g.resource_name = r.resource_name + WHERE e.identifier = ? + ORDER BY r.type, g.resource_name + `); + return stmt.all(identifier); + } finally { + db.close(); + } +} + module.exports = { demo_users, create_db_if_missing, @@ -279,10 +550,22 @@ module.exports = { ResourceExistsError, create_resource, delete_resource, + resource_exists, + list_resources, add_user, delete_user, set_grant, remove_grant, + remove_all_grants_for_entitlement, user_is_resource_admin, - PERMISSIONS + PERMISSIONS, + // User management + ensure_user, + // Entitlement management + create_entitlement, + entitlement_exists, + list_entitlements, + update_entitlement_description, + delete_entitlement, + get_grants_for_entitlement }; diff --git a/src/korp-auth.js b/src/korp-auth.js index 8fd23a5..87971fc 100644 --- a/src/korp-auth.js +++ b/src/korp-auth.js @@ -4,14 +4,23 @@ const cors = require('cors'); const path = require('path'); const fs = require('fs'); const cookieParser = require('cookie-parser'); +const debug = require('debug'); const config = require('./config'); +const logger = require('./logger'); + +// Debug namespaces for verbose logging (enable with DEBUG=korp-auth:*) +const debugHeaders = debug('korp-auth:headers'); +const debugJwt = debug('korp-auth:jwt'); +const debugAuth = debug('korp-auth:auth'); +const debugAdmin = debug('korp-auth:admin'); const app = express(); const SOCKET_PATH = config.socketPath; // Secret for signing JWTs const JWT_SECRET = fs.readFileSync(config.jwtPrivateKeyPath, 'utf8'); -const API_KEY = config.minkApiKey; +const MINK_API_KEY = config.minkApiKey; +const ADMIN_API_KEY = config.adminApiKey; const auth_cookie_name = config.authCookieName; @@ -30,91 +39,180 @@ app.use(cookieParser()); const fallback_redirect_uri = config.fallbackRedirectUri; // ============================================================================ -// LOGIN ENDPOINT (Available in both modes) +// HELPER FUNCTIONS // ============================================================================ /** - * GET /login - * - * Production mode: Triggers OIDC authentication and redirects with JWT - * Development mode: Shows login form + * Parse eduPersonEntitlement header into array of URNs + * They may arrive as a string (hopefully semicolon-separated) or array */ -app.get('/login', (req, res) => { - // PRODUCTION MODE: Act like /jwt endpoint with redirect - if (config.isProduction) { - const oidcSub = req.headers['oidc_claim_sub']; - const oidcEmail = req.headers['oidc_claim_email']; - const oidcName = req.headers['oidc_claim_name']; +function parseEntitlements(headerValue) { + if (!headerValue) { + return []; + } - if (!oidcSub) { - return res.status(401).json({ - error: 'Unauthorized', - message: 'No user identity from proxy. Ensure Apache mod_auth_openidc is configured and user is authenticated.' - }); + // If already an array, return it + if (Array.isArray(headerValue)) { + return headerValue; + } + + // If string, split by semicolon and trim whitespace + if (typeof headerValue === 'string') { + return headerValue + .split(';') + .map(entitlement => entitlement.trim()) + .filter(entitlement => entitlement.length > 0); + } + + return []; +} + +/** + * Parse affiliation header into array of values + */ +function parseAffiliations(headerValue) { + if (!headerValue) { + return []; + } + + // If already an array, return it + if (Array.isArray(headerValue)) { + return headerValue; + } + + // If string, split by semicolon and trim whitespace + if (typeof headerValue === 'string') { + return headerValue + .split(';') + .map(aff => aff.trim()) + .filter(aff => aff.length > 0); + } + + return []; +} + +/** + * Check if user has academic (ACA) status + * Based on eduPersonAffiliation, eduPersonScopedAffiliation, and eduPersonEntitlement claims + * + * Academic status is granted if ANY of these conditions are met: + * 1. Unscoped affiliation contains: member, student, faculty, or employee + * 2. CLARIN special case: member@clarin.eu affiliation + http://www.clarin.eu/entitlement/academic entitlement + * 3. Other scoped affiliation: (member|student|faculty|employee)@domain (but NOT member@clarin.eu) + * 4. LBR ACA entitlement: urn:nbn:fi:lb-2016110710@LBR + */ +function checkAcademicStatus(affiliations, scopedAffiliations, entitlements) { + const debugAca = debug('korp-auth:aca'); + + // Parse all inputs + const unscopedAffs = parseAffiliations(affiliations); + const scopedAffs = parseAffiliations(scopedAffiliations); + const ents = parseEntitlements(entitlements); + + debugAca('Checking ACA status:', { + unscopedAffiliations: unscopedAffs, + scopedAffiliations: scopedAffs, + entitlements: ents + }); + + // Academic affiliation values + const academicAffiliations = ['member', 'student', 'faculty', 'employee']; + + // 1. Check unscoped affiliations + for (const aff of unscopedAffs) { + if (academicAffiliations.includes(aff.toLowerCase())) { + debugAca('ACA granted via unscoped affiliation:', aff); + return true; } + } - // User is authenticated - generate JWT and redirect - const userSub = oidcSub; - const userEmail = oidcEmail || null; - const userName = oidcName || oidcEmail || oidcSub; - - console.log(`[Proxy/Login] Issuing JWT for user: ${userEmail || userSub}`); - - const scope = auth_db.get_user_scope(userSub); - - const token = jwt.sign( - { - sub: userSub, - email: userEmail, - name: userName, - idp: 'https://aai.kielipankki.fi', - scope: scope, - levels: auth_db.PERMISSIONS, - exp: Math.floor(Date.now() / 1000) + 3600, - iat: Math.floor(Date.now() / 1000) - }, - JWT_SECRET, - { algorithm: 'RS256' } - ); - - // Check for redirect parameter - const redirectTo = req.query.redirect || req.query.redirect_uri; - - if (redirectTo) { - // Validate redirect URL - try { - const redirectUrl = new URL(redirectTo); - const allowedOrigins = [ - 'https://www.kielipankki.fi', - 'https://kielipankki.fi', - ]; - - const isAllowed = allowedOrigins.some(origin => - redirectUrl.origin === origin || redirectUrl.href.startsWith(origin) - ); - - if (!isAllowed) { - console.warn(`[Login] Rejected redirect to untrusted origin: ${redirectUrl.origin}`); - return res.status(400).json({ - error: 'Invalid redirect URL', - message: 'Redirect destination is not in the allowed list' - }); - } + // 2. Check CLARIN special case + const hasClarinMember = scopedAffs.some(aff => + aff.toLowerCase() === 'member@clarin.eu' + ); + const hasClarinAcademicEntitlement = ents.some(ent => + ent === 'http://www.clarin.eu/entitlement/academic' + ); + if (hasClarinMember && hasClarinAcademicEntitlement) { + debugAca('ACA granted via CLARIN special case'); + return true; + } - const separator = redirectUrl.search ? '&' : '?'; - return res.redirect(`${redirectTo}${separator}jwt=${token}`); + // 3. Check other scoped affiliations (excluding CLARIN member@clarin.eu) + for (const aff of scopedAffs) { + const lowerAff = aff.toLowerCase(); - } catch (error) { - console.warn(`[Login] Invalid redirect URL: ${redirectTo}`); - return res.status(400).json({ - error: 'Invalid redirect URL', - message: 'Malformed URL' - }); + // Skip CLARIN member (already handled in special case) + if (lowerAff === 'member@clarin.eu') { + continue; + } + + // Check if affiliation matches academic patterns with @ (scoped) + for (const academicRole of academicAffiliations) { + // Pattern: role@domain (e.g., member@helsinki.fi, student@jyu.fi) + if (lowerAff.startsWith(academicRole + '@')) { + debugAca('ACA granted via scoped affiliation:', aff); + return true; } } + } + + // 4. Check LBR ACA entitlement + const hasLbrAca = ents.some(ent => + ent === 'urn:nbn:fi:lb-2016110710@LBR' + ); + if (hasLbrAca) { + debugAca('ACA granted via LBR entitlement'); + return true; + } + + debugAca('ACA not granted'); + return false; +} + +/** + * Admin API authentication middleware + * Requires X-API-Key header with valid ADMIN_API_KEY + */ +function requireAdminAuth(req, res, next) { + const apiKey = req.headers['x-api-key']; - // No redirect: return JWT as text - return res.send(token); + if (!apiKey) { + return res.status(401).json({ + error: 'unauthorized', + message: 'X-API-Key header required for admin endpoints' + }); + } + + if (apiKey !== ADMIN_API_KEY) { + logger.warn('Invalid admin API key attempt', 'Admin'); + return res.status(401).json({ + error: 'unauthorized', + message: 'Invalid API key' + }); + } + + // API key is valid, proceed to endpoint + next(); +} + +// ============================================================================ +// LOGIN ENDPOINT (Development mode only) +// ============================================================================ + +/** + * GET /login + * + * Production mode: Not available (Apache handles login/redirect) + * Development mode: Shows login form + */ +app.get('/login', (req, res) => { + // PRODUCTION MODE: Not available (Apache handles login/redirect) + if (config.isProduction) { + return res.status(404).json({ + error: 'Not available in production mode', + message: 'Login and redirect is handled by Apache. Use /jwt endpoint to get JWT token.' + }); } // DEVELOPMENT MODE: Show login form @@ -238,13 +336,26 @@ app.get('/logout', (req, res) => { * Development mode: Reads user from session cookie */ app.get('/jwt', (req, res) => { - let userSub, userEmail, userName; + let userSub, userEmail, userName, entitlements, isAcademic; // PRODUCTION MODE: Read user from Apache OIDC headers if (config.isProduction) { const oidcSub = req.headers['oidc_claim_sub']; const oidcEmail = req.headers['oidc_claim_email']; const oidcName = req.headers['oidc_claim_name']; + const oidcEntitlements = req.headers['oidc_claim_edupersonentitlement']; + const oidcAffiliation = req.headers['oidc_claim_edupersonaffiliation']; + const oidcScopedAffiliation = req.headers['oidc_claim_edupersonscopedaffiliation']; + + // Verbose header logging (enable with DEBUG=korp-auth:headers) + debugHeaders('OIDC headers received:', { + sub: oidcSub, + email: oidcEmail, + name: oidcName, + entitlements: oidcEntitlements, + affiliation: oidcAffiliation, + scopedAffiliation: oidcScopedAffiliation + }); if (!oidcSub) { return res.status(401).json({ @@ -256,8 +367,15 @@ app.get('/jwt', (req, res) => { userSub = oidcSub; userEmail = oidcEmail || null; userName = oidcName || oidcEmail || oidcSub; + entitlements = parseEntitlements(oidcEntitlements); + + // Check academic status + isAcademic = checkAcademicStatus(oidcAffiliation, oidcScopedAffiliation, oidcEntitlements); + + debugAuth('Parsed entitlements:', entitlements); + debugAuth('Academic status (ACA):', isAcademic); - console.log(`[Production] Issuing JWT for user: ${userEmail || userSub}`); + logger.info(`Issuing JWT for user: ${userEmail || userSub} (${entitlements.length} entitlements, ACA: ${isAcademic})`, 'JWT'); } // DEVELOPMENT MODE: Read user from cookie else { @@ -288,42 +406,66 @@ app.get('/jwt', (req, res) => { userSub = username; userEmail = username; userName = username; + entitlements = []; // No entitlements in development mode + isAcademic = false; // No ACA status in development mode - console.log(`[Development] Issuing JWT for user: ${username}`); + logger.info(`Issuing JWT for user: ${username}`, 'JWT'); } catch (error) { res.clearCookie(auth_cookie_name); return res.status(401).json({ error: 'unauthorized', message: 'Invalid or expired session' }); } } - // Look up user's resource permissions - const scope = auth_db.get_user_scope(userSub); + // Look up user's resource permissions (aggregated from user grants + entitlement grants) + const scope = auth_db.get_user_scope(userSub, entitlements); + + debugAuth('User scope retrieved:', scope); // Generate JWT with user identity + permissions - const token = jwt.sign( - { - sub: userSub, - email: userEmail, - name: userName, - idp: config.isProduction ? 'https://aai.kielipankki.fi' : 'kp-auth-local', - scope: scope, - levels: auth_db.PERMISSIONS, - exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour - iat: Math.floor(Date.now() / 1000) - }, - JWT_SECRET, - { algorithm: 'RS256' } - ); + const jwtPayload = { + sub: userSub, + email: userEmail, + name: userName, + idp: config.isProduction ? 'https://aai.kielipankki.fi' : 'kp-auth-local', + ACA: isAcademic, + scope: scope, + levels: auth_db.PERMISSIONS, + exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour + iat: Math.floor(Date.now() / 1000) + }; + + // Verbose JWT payload logging (enable with DEBUG=korp-auth:jwt) + debugJwt('JWT payload:', jwtPayload); + + const token = jwt.sign(jwtPayload, JWT_SECRET, { algorithm: 'RS256' }); res.send(token); }); /** * POST /resource/:resourcename - * Create a new resource and grant the creator ADMIN permission - * Requires JWT authentication + * Create a new user-uploaded resource and grant the creator ADMIN permission + * Requires Mink API key (Authorization header) and JWT authentication + * Only allows resources starting with "mink-" prefix */ app.post('/resource/:resourcename', (req, res) => { + const resourcename = req.params.resourcename; + const authHeader = req.headers.authorization; + + // Check 1: Verify Mink API key + if (authHeader !== "apikey " + MINK_API_KEY) { + return res.status(401).json({ error: 'unauthorized', message: 'Valid Mink API key required' }); + } + + // Check 2: Verify resource name starts with "mink-" + if (!resourcename.startsWith('mink-')) { + return res.status(400).json({ + error: 'invalid_resource_name', + message: 'User-uploaded resources must start with "mink-" prefix' + }); + } + + // Check 3: Verify JWT const authCode = req.body.jwt; if (!authCode) { return res.status(401).json({ error: 'unauthorized', message: 'JWT required in request body' }); @@ -332,41 +474,497 @@ app.post('/resource/:resourcename', (req, res) => { try { const decoded = jwt.verify(authCode, JWT_SECRET); const username = decoded.sub || decoded.email; - const resourcename = req.params.resourcename; + + // Ensure user exists in database before creating grant + auth_db.ensure_user(username); auth_db.create_resource(resourcename, "corpus"); - auth_db.set_grant(username, resourcename, auth_db.PERMISSIONS.ADMIN); + auth_db.set_grant({ userIdentifier: username, resourceName: resourcename, level: auth_db.PERMISSIONS.ADMIN }); - console.log(`[Resource] Created resource '${resourcename}' for user '${username}'`); + logger.info(`Created resource '${resourcename}' for user '${username}'`, 'Resource'); res.status(201).send(resourcename); } catch (error) { if (error instanceof auth_db.ResourceExistsError) { - return res.status(400).json({ error: 'resource already exists'}); + return res.status(400).json({ error: 'resource_exists', message: 'Resource already exists' }); + } else if (error instanceof jwt.JsonWebTokenError) { + // Covers JsonWebTokenError, TokenExpiredError, NotBeforeError + logger.warn(`Invalid auth token for resource creation: ${error.message}`, 'Resource'); + return res.status(401).json({ error: 'invalid_token', message: error.message }); } else { - console.log("Returning 401 due to invalid auth token:", error.message); - return res.status(401).json({ error: 'invalid auth token' }); + logger.error(`Unexpected error creating resource: ${error.message}`, 'Resource'); + return res.status(500).json({ error: 'internal_error', message: 'Failed to create resource' }); } } }); /** * DELETE /resource/:resourcename - * Delete a resource and all its grants - * Requires Mink API key (service-to-service authentication) + * Delete a user-uploaded resource and all its grants + * Requires Mink API key (Authorization header) and JWT authentication + * User must have ADMIN permission on the resource + * Only allows resources starting with "mink-" prefix */ app.delete('/resource/:resourcename', (req, res) => { const authHeader = req.headers.authorization; const resourcename = req.params.resourcename; - if (authHeader !== "apikey " + API_KEY) { - return res.status(401).json({ error: 'unauthorized', message: 'Valid API key required' }); + // Check 1: Verify Mink API key + if (authHeader !== "apikey " + MINK_API_KEY) { + return res.status(401).json({ error: 'unauthorized', message: 'Valid Mink API key required' }); + } + + // Check 2: Verify resource name starts with "mink-" + if (!resourcename.startsWith('mink-')) { + return res.status(400).json({ + error: 'invalid_resource_name', + message: 'User-uploaded resources must start with "mink-" prefix' + }); + } + + // Check 3: Verify JWT + const authCode = req.body.jwt; + if (!authCode) { + return res.status(401).json({ error: 'unauthorized', message: 'JWT required in request body' }); + } + + try { + const decoded = jwt.verify(authCode, JWT_SECRET); + const username = decoded.sub || decoded.email; + + // Check 4: Verify user has ADMIN permission on the resource + const userScope = decoded.scope; + const resourcePermission = userScope?.corpora?.[resourcename]; + + if (resourcePermission !== auth_db.PERMISSIONS.ADMIN) { + logger.warn(`User '${username}' attempted to delete resource '${resourcename}' without ADMIN permission`, 'Resource'); + return res.status(403).json({ + error: 'forbidden', + message: 'ADMIN permission required to delete this resource' + }); + } + + auth_db.delete_resource(resourcename); + logger.info(`Deleted resource '${resourcename}' by user '${username}'`, 'Resource'); + + // 204 No Content + return res.status(204).send(); + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + // Covers JsonWebTokenError, TokenExpiredError, NotBeforeError + logger.warn(`Invalid auth token for resource deletion: ${error.message}`, 'Resource'); + return res.status(401).json({ error: 'invalid_token', message: error.message }); + } else { + logger.error(`Unexpected error deleting resource: ${error.message}`, 'Resource'); + return res.status(500).json({ error: 'internal_error', message: 'Failed to delete resource' }); + } + } +}); + +// ============================================================================ +// ADMIN API ENDPOINTS (Production only, requires ADMIN_API_KEY) +// ============================================================================ + +/** + * GET /admin/entitlements + * List all entitlements with basic info and grant counts + * Requires X-API-Key header + */ +app.get('/admin/entitlements', requireAdminAuth, (req, res) => { + try { + const entitlements = auth_db.list_entitlements(); + + debugAdmin('Listed entitlements:', entitlements.length); + logger.info(`Listed ${entitlements.length} entitlements`, 'Admin'); + + res.status(200).json({ entitlements }); + } catch (error) { + logger.error(`Error listing entitlements: ${error.message}`, 'Admin'); + return res.status(500).json({ + error: 'internal_error', + message: 'Failed to list entitlements' + }); + } +}); + +/** + * GET /admin/entitlement/:identifier + * Get a single entitlement with full grant details + * Requires X-API-Key header + */ +app.get('/admin/entitlement/:identifier', requireAdminAuth, (req, res) => { + try { + const identifier = decodeURIComponent(req.params.identifier); + + debugAdmin('Fetching entitlement:', identifier); + + // Get entitlement metadata + const allEntitlements = auth_db.list_entitlements(); + const entitlement = allEntitlements.find(e => e.identifier === identifier); + + if (!entitlement) { + return res.status(404).json({ + error: 'not_found', + message: `Entitlement '${identifier}' not found` + }); + } + + // Get grants for this entitlement + const grants = auth_db.get_grants_for_entitlement(identifier); + + const result = { + identifier: entitlement.identifier, + description: entitlement.description, + created_at: entitlement.created_at, + updated_at: entitlement.updated_at, + grants: grants + }; + + debugAdmin('Fetched entitlement:', result); + logger.info(`Fetched entitlement: ${identifier}`, 'Admin'); + + res.status(200).json(result); + } catch (error) { + logger.error(`Error fetching entitlement: ${error.message}`, 'Admin'); + return res.status(500).json({ + error: 'internal_error', + message: 'Failed to fetch entitlement' + }); } +}); + +/** + * POST /admin/entitlement + * Create or update an entitlement (with optional grants) + * Requires X-API-Key header + * + * If grants array is provided, it REPLACES all existing grants for this entitlement. + * To keep existing grants, omit the grants field entirely. + * To remove all grants, pass an empty array: "grants": [] + * + * Request body: + * { + * "identifier": "urn:nbn:fi:lb-123", + * "description": "Description text", + * "grants": [ // optional - if provided, replaces all existing grants + * {"resourceName": "corpus-1", "level": 1}, + * {"resourceName": "corpus-2", "level": 2} + * ] + * } + */ +app.post('/admin/entitlement', requireAdminAuth, (req, res) => { + try { + const { identifier, description, grants } = req.body; + + debugAdmin('POST /admin/entitlement request:', { identifier, description, grants }); + + // Validate required fields + if (!identifier || !description) { + return res.status(400).json({ + error: 'validation_error', + message: 'Both "identifier" and "description" fields are required' + }); + } + + // Validate grants if provided + if (grants) { + if (!Array.isArray(grants)) { + return res.status(400).json({ + error: 'validation_error', + message: '"grants" must be an array' + }); + } + + for (const grant of grants) { + if (!grant.resourceName || !grant.level) { + return res.status(400).json({ + error: 'validation_error', + message: 'Each grant must have "resourceName" and "level" fields' + }); + } + + if (![1, 2, 3].includes(grant.level)) { + return res.status(400).json({ + error: 'validation_error', + message: 'Grant level must be 1 (READ), 2 (WRITE), or 3 (ADMIN)' + }); + } + } + } + + // Check if entitlement exists + const exists = auth_db.entitlement_exists(identifier); + + // Create or update entitlement + if (exists) { + auth_db.update_entitlement_description(identifier, description); + } else { + auth_db.create_entitlement(identifier, description); + } + + // Replace grants if provided (delete existing, then add new) + let grantsSet = 0; + if (grants) { + // First, remove all existing grants for this entitlement + auth_db.remove_all_grants_for_entitlement(identifier); + + // Then add the new grants + for (const grant of grants) { + auth_db.set_grant({ + entitlementIdentifier: identifier, + resourceName: grant.resourceName, + level: grant.level + }); + grantsSet++; + } + } + + const result = { + identifier, + description, + created: !exists, + grantsSet + }; + + debugAdmin('Created/updated entitlement:', result); + logger.info(`${exists ? 'Updated' : 'Created'} entitlement: ${identifier} (${grantsSet} grants)`, 'Admin'); + + res.status(exists ? 200 : 201).json(result); + } catch (error) { + logger.error(`Error creating/updating entitlement: ${error.message}`, 'Admin'); + return res.status(500).json({ + error: 'internal_error', + message: 'Failed to create/update entitlement' + }); + } +}); + +/** + * DELETE /admin/entitlement/:identifier + * Delete an entitlement and all associated grants + * Requires X-API-Key header + */ +app.delete('/admin/entitlement/:identifier', requireAdminAuth, (req, res) => { + try { + const identifier = decodeURIComponent(req.params.identifier); + + debugAdmin('Deleting entitlement:', identifier); + + auth_db.delete_entitlement(identifier); + + logger.info(`Deleted entitlement: ${identifier}`, 'Admin'); + + // 204 No Content (idempotent - even if didn't exist) + res.status(204).send(); + } catch (error) { + logger.error(`Error deleting entitlement: ${error.message}`, 'Admin'); + return res.status(500).json({ + error: 'internal_error', + message: 'Failed to delete entitlement' + }); + } +}); + +/** + * POST /admin/grant + * Add or update a single grant for an entitlement + * Requires X-API-Key header + * + * Request body: + * { + * "entitlementIdentifier": "urn:nbn:fi:lb-123", + * "resourceName": "corpus-1", + * "level": 1 + * } + */ +app.post('/admin/grant', requireAdminAuth, (req, res) => { + try { + const { entitlementIdentifier, resourceName, level } = req.body; + + debugAdmin('POST /admin/grant request:', { entitlementIdentifier, resourceName, level }); + + // Validate required fields + if (!entitlementIdentifier || !resourceName || !level) { + return res.status(400).json({ + error: 'validation_error', + message: 'All fields required: "entitlementIdentifier", "resourceName", "level"' + }); + } + + // Validate level + if (![1, 2, 3].includes(level)) { + return res.status(400).json({ + error: 'validation_error', + message: 'Level must be 1 (READ), 2 (WRITE), or 3 (ADMIN)' + }); + } + + // Set the grant (uses upsert) + auth_db.set_grant({ entitlementIdentifier, resourceName, level }); - auth_db.delete_resource(resourcename); - console.log(`[Resource] Deleted resource '${resourcename}'`); + const result = { entitlementIdentifier, resourceName, level }; - // 204 No Content (even if it didn't exist) - return res.status(204).send(); + debugAdmin('Set grant:', result); + logger.info(`Set grant: ${entitlementIdentifier} -> ${resourceName} (level ${level})`, 'Admin'); + + res.status(200).json(result); + } catch (error) { + // Check for foreign key constraint failures + if (error.message && error.message.includes('FOREIGN KEY constraint failed')) { + return res.status(404).json({ + error: 'not_found', + message: 'Entitlement or resource not found' + }); + } + + logger.error(`Error setting grant: ${error.message}`, 'Admin'); + return res.status(500).json({ + error: 'internal_error', + message: 'Failed to set grant' + }); + } +}); + +/** + * DELETE /admin/grant + * Remove a grant for an entitlement + * Requires X-API-Key header + * Query params: ?entitlementIdentifier=...&resourceName=... + */ +app.delete('/admin/grant', requireAdminAuth, (req, res) => { + try { + const { entitlementIdentifier, resourceName } = req.query; + + debugAdmin('DELETE /admin/grant request:', { entitlementIdentifier, resourceName }); + + // Validate query params + if (!entitlementIdentifier || !resourceName) { + return res.status(400).json({ + error: 'validation_error', + message: 'Query params required: "entitlementIdentifier" and "resourceName"' + }); + } + + // Remove the grant + auth_db.remove_grant({ entitlementIdentifier, resourceName }); + + logger.info(`Removed grant: ${entitlementIdentifier} -> ${resourceName}`, 'Admin'); + + // 204 No Content + res.status(204).send(); + } catch (error) { + logger.error(`Error removing grant: ${error.message}`, 'Admin'); + return res.status(500).json({ + error: 'internal_error', + message: 'Failed to remove grant' + }); + } +}); + +/** + * GET /admin/resources + * List all resources with grant counts + * Requires X-API-Key header + */ +app.get('/admin/resources', requireAdminAuth, (req, res) => { + try { + const resources = auth_db.list_resources(); + + debugAdmin('Listed resources:', resources.length); + logger.info(`Listed ${resources.length} resources`, 'Admin'); + + res.status(200).json({ resources }); + } catch (error) { + logger.error(`Error listing resources: ${error.message}`, 'Admin'); + return res.status(500).json({ + error: 'internal_error', + message: 'Failed to list resources' + }); + } +}); + +/** + * POST /admin/resource + * Create a new resource + * Requires X-API-Key header + * + * Request body: + * { + * "name": "corpus-name", + * "type": "corpus" // corpus, metadata, or other + * } + */ +app.post('/admin/resource', requireAdminAuth, (req, res) => { + try { + const { name, type } = req.body; + + debugAdmin('POST /admin/resource request:', { name, type }); + + // Validate required fields + if (!name || !type) { + return res.status(400).json({ + error: 'validation_error', + message: 'Both "name" and "type" fields are required' + }); + } + + // Validate type + if (!['corpus', 'metadata', 'other'].includes(type)) { + return res.status(400).json({ + error: 'validation_error', + message: 'Type must be "corpus", "metadata", or "other"' + }); + } + + // Create the resource + auth_db.create_resource(name, type); + + const result = { name, type, created: true }; + + debugAdmin('Created resource:', result); + logger.info(`Created resource: ${name} (type: ${type})`, 'Admin'); + + res.status(201).json(result); + } catch (error) { + // Check for duplicate resource + if (error instanceof auth_db.ResourceExistsError) { + return res.status(409).json({ + error: 'conflict', + message: `Resource '${req.body.name}' already exists` + }); + } + + logger.error(`Error creating resource: ${error.message}`, 'Admin'); + return res.status(500).json({ + error: 'internal_error', + message: 'Failed to create resource' + }); + } +}); + +/** + * DELETE /admin/resource/:name + * Delete a resource and all associated grants + * Requires X-API-Key header + */ +app.delete('/admin/resource/:name', requireAdminAuth, (req, res) => { + try { + const name = decodeURIComponent(req.params.name); + + debugAdmin('Deleting resource:', name); + + auth_db.delete_resource(name); + + logger.info(`Deleted resource: ${name}`, 'Admin'); + + // 204 No Content (idempotent - even if didn't exist) + res.status(204).send(); + } catch (error) { + logger.error(`Error deleting resource: ${error.message}`, 'Admin'); + return res.status(500).json({ + error: 'internal_error', + message: 'Failed to delete resource' + }); + } }); // ============================================================================ @@ -414,13 +1012,13 @@ app.listen(SOCKET_PATH, () => { try { fs.chmodSync(SOCKET_PATH, '666'); // rw-rw-rw- } catch (err) { - console.warn('Could not set socket permissions:', err.message); + logger.warn(`Could not set socket permissions: ${err.message}`, 'Startup'); } }); // Graceful shutdown - clean up socket file process.on('SIGINT', () => { - console.log('\nShutting down gracefully...'); + logger.info('Shutting down gracefully...', 'Shutdown'); if (fs.existsSync(SOCKET_PATH)) { fs.unlinkSync(SOCKET_PATH); } @@ -428,7 +1026,7 @@ process.on('SIGINT', () => { }); process.on('SIGTERM', () => { - console.log('\nReceived SIGTERM, shutting down gracefully...'); + logger.info('Received SIGTERM, shutting down gracefully...', 'Shutdown'); if (fs.existsSync(SOCKET_PATH)) { fs.unlinkSync(SOCKET_PATH); } diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..ddbf2d3 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,62 @@ +/** + * Custom logging module for korp-auth + * + * Provides structured logging with timestamps and optional tags. + * Can be extended in the future for log levels, file output, etc. + */ + +const config = require('./config'); + +/** + * Format a log message with timestamp and optional tag + * @param {string} message - The log message + * @param {string} tag - Optional tag (e.g., 'Production', 'Resource', 'Auth') + * @returns {string} Formatted log message + */ +function formatMessage(message, tag = null) { + const timestamp = new Date().toISOString(); + const env = config.isProduction ? 'PROD' : 'DEV'; + + let formatted = `[${timestamp}] [${env}]`; + + if (tag) { + formatted += ` [${tag}]`; + } + + formatted += ` ${message}`; + + return formatted; +} + +/** + * Log an informational message to stdout + * @param {string} message - The message to log + * @param {string} tag - Optional tag + */ +function info(message, tag = null) { + console.log(formatMessage(message, tag)); +} + +/** + * Log a warning message to stdout + * @param {string} message - The message to log + * @param {string} tag - Optional tag + */ +function warn(message, tag = null) { + console.warn(formatMessage(message, tag)); +} + +/** + * Log an error message to stderr + * @param {string} message - The message to log + * @param {string} tag - Optional tag + */ +function error(message, tag = null) { + console.error(formatMessage(message, tag)); +} + +module.exports = { + info, + warn, + error +}; diff --git a/test/aca.test.js b/test/aca.test.js new file mode 100644 index 0000000..f7774c9 --- /dev/null +++ b/test/aca.test.js @@ -0,0 +1,223 @@ +#!/usr/bin/env node + +/** + * Test script for ACA status checking logic + * Run with: node test/aca.test.js + */ + +// Simplified versions of the helper functions for testing +function parseEntitlements(headerValue) { + if (!headerValue) return []; + if (Array.isArray(headerValue)) return headerValue; + if (typeof headerValue === 'string') { + return headerValue + .split(';') + .map(entitlement => entitlement.trim()) + .filter(entitlement => entitlement.length > 0); + } + return []; +} + +function parseAffiliations(headerValue) { + if (!headerValue) return []; + if (Array.isArray(headerValue)) return headerValue; + if (typeof headerValue === 'string') { + return headerValue + .split(';') + .map(aff => aff.trim()) + .filter(aff => aff.length > 0); + } + return []; +} + +function checkAcademicStatus(affiliations, scopedAffiliations, entitlements) { + const unscopedAffs = parseAffiliations(affiliations); + const scopedAffs = parseAffiliations(scopedAffiliations); + const ents = parseEntitlements(entitlements); + + const academicAffiliations = ['member', 'student', 'faculty', 'employee']; + + // 1. Check unscoped affiliations + for (const aff of unscopedAffs) { + if (academicAffiliations.includes(aff.toLowerCase())) { + return true; + } + } + + // 2. Check CLARIN special case + const hasClarinMember = scopedAffs.some(aff => + aff.toLowerCase() === 'member@clarin.eu' + ); + const hasClarinAcademicEntitlement = ents.some(ent => + ent === 'http://www.clarin.eu/entitlement/academic' + ); + if (hasClarinMember && hasClarinAcademicEntitlement) { + return true; + } + + // 3. Check other scoped affiliations (excluding CLARIN member@clarin.eu) + for (const aff of scopedAffs) { + const lowerAff = aff.toLowerCase(); + if (lowerAff === 'member@clarin.eu') { + continue; + } + for (const academicRole of academicAffiliations) { + if (lowerAff.startsWith(academicRole + '@')) { + return true; + } + } + } + + // 4. Check LBR ACA entitlement + const hasLbrAca = ents.some(ent => + ent === 'urn:nbn:fi:lb-2016110710@LBR' + ); + if (hasLbrAca) { + return true; + } + + return false; +} + +// Test cases +const tests = [ + { + name: "Unscoped affiliation: member", + aff: "member", + scopedAff: null, + ent: null, + expected: true + }, + { + name: "Unscoped affiliation: student", + aff: "student", + scopedAff: null, + ent: null, + expected: true + }, + { + name: "Unscoped affiliation: faculty", + aff: "faculty", + scopedAff: null, + ent: null, + expected: true + }, + { + name: "Unscoped affiliation: employee", + aff: "employee", + scopedAff: null, + ent: null, + expected: true + }, + { + name: "Unscoped affiliation: affiliate (should not grant ACA)", + aff: "affiliate", + scopedAff: null, + ent: null, + expected: false + }, + { + name: "Unscoped affiliation: library-walk-in (should not grant ACA)", + aff: "library-walk-in", + scopedAff: null, + ent: null, + expected: false + }, + { + name: "CLARIN special case: member@clarin.eu with academic entitlement", + aff: null, + scopedAff: "member@clarin.eu", + ent: "http://www.clarin.eu/entitlement/academic", + expected: true + }, + { + name: "CLARIN special case: member@clarin.eu WITHOUT academic entitlement (should not grant ACA)", + aff: null, + scopedAff: "member@clarin.eu", + ent: null, + expected: false + }, + { + name: "Scoped affiliation: member@helsinki.fi", + aff: null, + scopedAff: "member@helsinki.fi", + ent: null, + expected: true + }, + { + name: "Scoped affiliation: student@jyu.fi", + aff: null, + scopedAff: "student@jyu.fi", + ent: null, + expected: true + }, + { + name: "Scoped affiliation: faculty@utu.fi", + aff: null, + scopedAff: "faculty@utu.fi", + ent: null, + expected: true + }, + { + name: "Scoped affiliation: employee@aalto.fi", + aff: null, + scopedAff: "employee@aalto.fi", + ent: null, + expected: true + }, + { + name: "LBR ACA entitlement", + aff: null, + scopedAff: null, + ent: "urn:nbn:fi:lb-2016110710@LBR", + expected: true + }, + { + name: "No academic credentials", + aff: null, + scopedAff: null, + ent: null, + expected: false + }, + { + name: "Multiple entitlements including LBR ACA", + aff: null, + scopedAff: null, + ent: "urn:nbn:fi:lb-2022031701@LBR;urn:nbn:fi:lb-2016110710@LBR", + expected: true + }, + { + name: "Multiple affiliations including member", + aff: "member;employee", + scopedAff: null, + ent: null, + expected: true + } +]; + +console.log('Testing ACA Status Checking Logic'); +console.log('='.repeat(70)); + +let passed = 0; +let failed = 0; + +tests.forEach(test => { + const result = checkAcademicStatus(test.aff, test.scopedAff, test.ent); + const status = result === test.expected ? '✓ PASS' : '✗ FAIL'; + + if (result === test.expected) { + passed++; + } else { + failed++; + } + + console.log(`${status} - ${test.name}`); + if (result !== test.expected) { + console.log(` Expected: ${test.expected}, Got: ${result}`); + } +}); + +console.log('='.repeat(70)); +console.log(`Results: ${passed} passed, ${failed} failed`); + +process.exit(failed > 0 ? 1 : 0); diff --git a/test/db.test.js b/test/db.test.js new file mode 100644 index 0000000..4919164 --- /dev/null +++ b/test/db.test.js @@ -0,0 +1,478 @@ +/** + * Database tests for entitlement-based authorization + * + * Run with: npm test + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const Database = require('better-sqlite3'); + +// Set test environment +process.env.NODE_ENV = 'development'; +process.env.DB_PATH = path.join(__dirname, 'test.sqlite3'); +process.env.DEMO_USERS = JSON.stringify({ + 'demo@example.com': { password: 'password123' }, + 'tutkija@kielipankki.fi': { password: '123' } +}); + +const db = require('../src/db'); +const config = require('../src/config'); + +// Test database path +const TEST_DB_PATH = config.dbPath; + +/** + * Clean up test database before each test suite + */ +function setupTestDatabase() { + if (fs.existsSync(TEST_DB_PATH)) { + fs.unlinkSync(TEST_DB_PATH); + } + db.create_db_if_missing(); +} + +/** + * Clean up test database after tests + */ +function teardownTestDatabase() { + if (fs.existsSync(TEST_DB_PATH)) { + fs.unlinkSync(TEST_DB_PATH); + } +} + +// ============================================================================ +// Test Suite: Database Schema +// ============================================================================ + +function testDatabaseSchema() { + console.log('\n=== Testing Database Schema ==='); + + setupTestDatabase(); + + const sqlite = new Database(TEST_DB_PATH); + try { + // Test: All tables exist + const tables = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all(); + const tableNames = tables.map(t => t.name); + + assert(tableNames.includes('USERS'), 'USERS table should exist'); + assert(tableNames.includes('ENTITLEMENTS'), 'ENTITLEMENTS table should exist'); + assert(tableNames.includes('RESOURCES'), 'RESOURCES table should exist'); + assert(tableNames.includes('GRANTS'), 'GRANTS table should exist'); + console.log('✓ All required tables exist'); + + // Test: GRANTS table has correct columns + const grantsSchema = sqlite.prepare("PRAGMA table_info(GRANTS)").all(); + const columnNames = grantsSchema.map(col => col.name); + + assert(columnNames.includes('user_id'), 'GRANTS should have user_id column'); + assert(columnNames.includes('entitlement_id'), 'GRANTS should have entitlement_id column'); + assert(columnNames.includes('resource_name'), 'GRANTS should have resource_name column'); + assert(columnNames.includes('permission_level'), 'GRANTS should have permission_level column'); + console.log('✓ GRANTS table has correct columns'); + + // Test: Unique indexes exist + const indexes = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='GRANTS' ORDER BY name").all(); + const indexNames = indexes.map(i => i.name); + + assert(indexNames.includes('idx_grants_user_resource'), 'Should have unique index for user+resource'); + assert(indexNames.includes('idx_grants_entitlement_resource'), 'Should have unique index for entitlement+resource'); + console.log('✓ Unique indexes exist for upsert operations'); + + } finally { + sqlite.close(); + } +} + +// ============================================================================ +// Test Suite: Entitlement Management +// ============================================================================ + +function testEntitlementManagement() { + console.log('\n=== Testing Entitlement Management ==='); + + setupTestDatabase(); + + // Test: Create entitlement + const entId = db.create_entitlement('urn:nbn:fi:lb-2022031701@LBR', 'Test Entitlement'); + assert(typeof entId === 'number', 'create_entitlement should return entitlement ID'); + console.log('✓ Create entitlement'); + + // Test: Entitlement exists + assert(db.entitlement_exists('urn:nbn:fi:lb-2022031701@LBR'), 'Created entitlement should exist'); + assert(!db.entitlement_exists('urn:nbn:fi:lb-9999999999@LBR'), 'Non-existent entitlement should not exist'); + console.log('✓ Check entitlement exists'); + + // Test: List entitlements + const entitlements = db.list_entitlements(); + assert(Array.isArray(entitlements), 'list_entitlements should return array'); + assert(entitlements.length === 1, 'Should have 1 entitlement'); + assert(entitlements[0].identifier === 'urn:nbn:fi:lb-2022031701@LBR', 'Should return correct URN'); + assert(entitlements[0].description === 'Test Entitlement', 'Should return correct description'); + console.log('✓ List entitlements'); + + // Test: Update entitlement description + db.update_entitlement_description('urn:nbn:fi:lb-2022031701@LBR', 'Updated Description'); + const updated = db.list_entitlements(); + assert(updated[0].description === 'Updated Description', 'Description should be updated'); + console.log('✓ Update entitlement description'); + + // Test: Delete entitlement + const deleted = db.delete_entitlement('urn:nbn:fi:lb-2022031701@LBR'); + assert(deleted === true, 'delete_entitlement should return true when deleted'); + assert(!db.entitlement_exists('urn:nbn:fi:lb-2022031701@LBR'), 'Entitlement should no longer exist'); + console.log('✓ Delete entitlement'); +} + +// ============================================================================ +// Test Suite: Grant Management +// ============================================================================ + +function testGrantManagement() { + console.log('\n=== Testing Grant Management ==='); + + setupTestDatabase(); + + // Setup test data + db.create_resource('test-corpus', 'corpus'); + db.create_entitlement('urn:nbn:fi:lb-2022031701@LBR', 'Test Entitlement'); + + // Test: Set grant for entitlement + db.set_grant({ entitlementIdentifier: 'urn:nbn:fi:lb-2022031701@LBR', resourceName: 'test-corpus', level: 1 }); + const grants = db.get_grants_for_entitlement('urn:nbn:fi:lb-2022031701@LBR'); + assert(grants.length === 1, 'Should have 1 grant'); + assert(grants[0].resource_name === 'test-corpus', 'Grant should be for correct resource'); + assert(grants[0].permission_level === 1, 'Grant should have correct permission level'); + console.log('✓ Set grant for entitlement'); + + // Test: Upsert grant (update existing) + db.set_grant({ entitlementIdentifier: 'urn:nbn:fi:lb-2022031701@LBR', resourceName: 'test-corpus', level: 2 }); + const updated = db.get_grants_for_entitlement('urn:nbn:fi:lb-2022031701@LBR'); + assert(updated.length === 1, 'Should still have 1 grant (no duplicate)'); + assert(updated[0].permission_level === 2, 'Permission level should be updated'); + console.log('✓ Upsert grant (update existing)'); + + // Test: Set grant for user + db.set_grant({ userIdentifier: 'demo@example.com', resourceName: 'test-corpus', level: 3 }); + const userScope = db.get_user_scope('demo@example.com', []); + assert(userScope.corpora['test-corpus'] === 3, 'User should have ADMIN permission'); + console.log('✓ Set grant for user'); + + // Test: Remove grant + db.remove_grant({ entitlementIdentifier: 'urn:nbn:fi:lb-2022031701@LBR', resourceName: 'test-corpus' }); + const afterRemove = db.get_grants_for_entitlement('urn:nbn:fi:lb-2022031701@LBR'); + assert(afterRemove.length === 0, 'Grant should be removed'); + console.log('✓ Remove grant'); + + // Test: Remove all grants for entitlement + db.create_resource('corpus-a', 'corpus'); + db.create_resource('corpus-b', 'corpus'); + db.set_grant({ entitlementIdentifier: 'urn:nbn:fi:lb-2022031701@LBR', resourceName: 'corpus-a', level: 1 }); + db.set_grant({ entitlementIdentifier: 'urn:nbn:fi:lb-2022031701@LBR', resourceName: 'corpus-b', level: 2 }); + const beforeRemoveAll = db.get_grants_for_entitlement('urn:nbn:fi:lb-2022031701@LBR'); + assert(beforeRemoveAll.length === 2, 'Should have 2 grants before remove all'); + db.remove_all_grants_for_entitlement('urn:nbn:fi:lb-2022031701@LBR'); + const afterRemoveAll = db.get_grants_for_entitlement('urn:nbn:fi:lb-2022031701@LBR'); + assert(afterRemoveAll.length === 0, 'All grants should be removed'); + // Verify user grant was not affected + const userScopeAfter = db.get_user_scope('demo@example.com', []); + assert(userScopeAfter.corpora['test-corpus'] === 3, 'User grant should not be affected'); + console.log('✓ Remove all grants for entitlement'); +} + +// ============================================================================ +// Test Suite: Permission Aggregation +// ============================================================================ + +function testPermissionAggregation() { + console.log('\n=== Testing Permission Aggregation ==='); + + setupTestDatabase(); + + // Setup test data + db.create_resource('corpus-1', 'corpus'); + db.create_resource('corpus-2', 'corpus'); + db.create_resource('metadata-1', 'metadata'); + + db.create_entitlement('urn:nbn:fi:lb-2022031701@LBR', 'Research Access'); + db.create_entitlement('urn:nbn:fi:lb-2023050901@LBR', 'Special Access'); + + // Set up conflicting permissions + db.set_grant({ entitlementIdentifier: 'urn:nbn:fi:lb-2022031701@LBR', resourceName: 'corpus-1', level: 1 }); // READ + db.set_grant({ entitlementIdentifier: 'urn:nbn:fi:lb-2023050901@LBR', resourceName: 'corpus-1', level: 3 }); // ADMIN + db.set_grant({ entitlementIdentifier: 'urn:nbn:fi:lb-2022031701@LBR', resourceName: 'corpus-2', level: 2 }); // WRITE + + // Set user-specific grant + db.set_grant({ userIdentifier: 'demo@example.com', resourceName: 'metadata-1', level: 2 }); // WRITE + + // Test: Permission aggregation with MAX() + const scope = db.get_user_scope('demo@example.com', [ + 'urn:nbn:fi:lb-2022031701@LBR', + 'urn:nbn:fi:lb-2023050901@LBR' + ]); + + assert(scope.corpora['corpus-1'] === 3, 'Should select highest permission (ADMIN over READ)'); + assert(scope.corpora['corpus-2'] === 2, 'Should have WRITE permission'); + assert(scope.metadata['metadata-1'] === 2, 'Should have user-specific permission'); + console.log('✓ Permission aggregation uses MAX() correctly'); + + // Test: User without entitlements only gets direct grants + const directOnly = db.get_user_scope('demo@example.com', []); + assert(!directOnly.corpora, 'Should not have corpus access without entitlements'); + assert(directOnly.metadata['metadata-1'] === 2, 'Should still have direct user grant'); + console.log('✓ Direct grants work without entitlements'); + + // Test: User with entitlements but no direct grants + db.add_user('other@example.com'); + const entitlementsOnly = db.get_user_scope('other@example.com', ['urn:nbn:fi:lb-2022031701@LBR']); + assert(entitlementsOnly.corpora['corpus-1'] === 1, 'Should have entitlement-based access'); + assert(entitlementsOnly.corpora['corpus-2'] === 2, 'Should have entitlement-based access'); + console.log('✓ Entitlement-only access works'); +} + +// ============================================================================ +// Test Suite: User Management +// ============================================================================ + +function testUserManagement() { + console.log('\n=== Testing User Management ==='); + + setupTestDatabase(); + + // Test: JIT user provisioning + const userId = db.ensure_user('newuser@example.com'); + assert(typeof userId === 'number', 'ensure_user should return user ID'); + assert(db.user_exists('newuser@example.com'), 'User should exist after ensure_user'); + console.log('✓ JIT user provisioning (ensure_user)'); + + // Test: ensure_user is idempotent + const userId2 = db.ensure_user('newuser@example.com'); + assert(userId === userId2, 'ensure_user should return same ID for existing user'); + console.log('✓ ensure_user is idempotent'); + + // Test: Add user + db.add_user('another@example.com'); + assert(db.user_exists('another@example.com'), 'User should exist after add_user'); + console.log('✓ Add user'); + + // Test: Delete user cascades to grants + db.create_resource('test-resource', 'corpus'); + db.set_grant({ userIdentifier: 'another@example.com', resourceName: 'test-resource', level: 1 }); + db.delete_user('another@example.com'); + assert(!db.user_exists('another@example.com'), 'User should be deleted'); + + // Verify grant was also deleted (CASCADE) + const sqlite = new Database(TEST_DB_PATH); + try { + const grants = sqlite.prepare("SELECT COUNT(*) as count FROM GRANTS WHERE user_id IS NOT NULL").get(); + // Should only have demo users' grants left + assert(grants.count >= 0, 'Grants should be cascaded on user delete'); + } finally { + sqlite.close(); + } + console.log('✓ Delete user cascades to grants'); +} + +// ============================================================================ +// Test Suite: Resource Management +// ============================================================================ + +function testResourceManagement() { + console.log('\n=== Testing Resource Management ==='); + + setupTestDatabase(); + + // Test: Create resource + db.create_resource('new-corpus', 'corpus'); + const sqlite = new Database(TEST_DB_PATH); + try { + const resource = sqlite.prepare("SELECT * FROM RESOURCES WHERE resource_name = ?").get('new-corpus'); + assert(resource !== undefined, 'Resource should exist'); + assert(resource.type === 'corpus', 'Resource should have correct type'); + } finally { + sqlite.close(); + } + console.log('✓ Create resource'); + + // Test: resource_exists + assert(db.resource_exists('new-corpus'), 'Created resource should exist'); + assert(!db.resource_exists('nonexistent-corpus'), 'Non-existent resource should not exist'); + console.log('✓ Check resource exists'); + + // Test: list_resources + db.create_resource('another-corpus', 'corpus'); + db.create_resource('test-metadata', 'metadata'); + const resources = db.list_resources(); + assert(Array.isArray(resources), 'list_resources should return array'); + assert(resources.length === 3, 'Should have 3 resources'); + const corpusResource = resources.find(r => r.resource_name === 'new-corpus'); + assert(corpusResource !== undefined, 'Should find new-corpus'); + assert(corpusResource.type === 'corpus', 'Should have correct type'); + assert(corpusResource.grant_count === 0, 'Should have 0 grants initially'); + console.log('✓ List resources'); + + // Test: list_resources includes grant counts + db.set_grant({ userIdentifier: 'demo@example.com', resourceName: 'new-corpus', level: 1 }); + db.create_entitlement('urn:test@LBR', 'Test'); + db.set_grant({ entitlementIdentifier: 'urn:test@LBR', resourceName: 'new-corpus', level: 2 }); + const resourcesWithGrants = db.list_resources(); + const corpusWithGrants = resourcesWithGrants.find(r => r.resource_name === 'new-corpus'); + assert(corpusWithGrants.grant_count === 2, 'Should count grants correctly'); + console.log('✓ List resources includes grant counts'); + + // Clean up for next test + db.delete_resource('another-corpus'); + db.delete_resource('test-metadata'); + db.delete_entitlement('urn:test@LBR'); + + // Test: Cannot create duplicate resource + let errorThrown = false; + try { + db.create_resource('new-corpus', 'corpus'); + } catch (error) { + errorThrown = true; + assert(error instanceof db.ResourceExistsError, 'Should throw ResourceExistsError'); + } + assert(errorThrown, 'Should throw error for duplicate resource'); + console.log('✓ Duplicate resource throws error'); + + // Test: Delete resource cascades to grants + db.set_grant({ userIdentifier: 'demo@example.com', resourceName: 'new-corpus', level: 1 }); + db.delete_resource('new-corpus'); + + const sqlite2 = new Database(TEST_DB_PATH); + try { + const resource = sqlite2.prepare("SELECT * FROM RESOURCES WHERE resource_name = ?").get('new-corpus'); + assert(resource === undefined, 'Resource should be deleted'); + + const grants = sqlite2.prepare("SELECT COUNT(*) as count FROM GRANTS WHERE resource_name = ?").get('new-corpus'); + assert(grants.count === 0, 'Grants should be cascaded on resource delete'); + } finally { + sqlite2.close(); + } + console.log('✓ Delete resource cascades to grants'); +} + +// ============================================================================ +// Test Suite: JWT Flow Integration +// ============================================================================ + +function testJwtFlowIntegration() { + console.log('\n=== Testing JWT Flow Integration ==='); + + setupTestDatabase(); + + // Setup test data + db.create_resource('corpus-research', 'corpus'); + db.create_resource('corpus-special', 'corpus'); + db.create_resource('corpus-public', 'corpus'); + + db.create_entitlement('urn:nbn:fi:lb-2022031701@LBR', 'Language Bank Research Access'); + db.create_entitlement('urn:nbn:fi:lb-2023050901@LBR', 'Special Collection Access'); + + db.set_grant({ entitlementIdentifier: 'urn:nbn:fi:lb-2022031701@LBR', resourceName: 'corpus-research', level: 1 }); // READ + db.set_grant({ entitlementIdentifier: 'urn:nbn:fi:lb-2023050901@LBR', resourceName: 'corpus-special', level: 2 }); // WRITE + + // Test: User with multiple entitlements + const scope1 = db.get_user_scope('demo@example.com', [ + 'urn:nbn:fi:lb-2022031701@LBR', + 'urn:nbn:fi:lb-2023050901@LBR' + ]); + assert(scope1.corpora['corpus-research'] === 1, 'Should have READ access via first entitlement'); + assert(scope1.corpora['corpus-special'] === 2, 'Should have WRITE access via second entitlement'); + console.log('✓ User with multiple entitlements gets aggregated permissions'); + + // Test: User with single entitlement + const scope2 = db.get_user_scope('demo@example.com', ['urn:nbn:fi:lb-2022031701@LBR']); + assert(scope2.corpora['corpus-research'] === 1, 'Should have READ access via entitlement'); + assert(!scope2.corpora['corpus-special'], 'Should not have access to other corpus'); + console.log('✓ User with single entitlement gets limited permissions'); + + // Test: User with no entitlements + const scope3 = db.get_user_scope('demo@example.com', []); + assert(Object.keys(scope3).length === 0, 'Should have no permissions without entitlements'); + console.log('✓ User with no entitlements gets no permissions'); + + // Test: Combined user grants + entitlement grants + db.set_grant({ userIdentifier: 'demo@example.com', resourceName: 'corpus-public', level: 3 }); // Direct ADMIN grant + const scope4 = db.get_user_scope('demo@example.com', ['urn:nbn:fi:lb-2022031701@LBR']); + assert(scope4.corpora['corpus-public'] === 3, 'Should have direct ADMIN grant'); + assert(scope4.corpora['corpus-research'] === 1, 'Should have entitlement READ grant'); + console.log('✓ Direct grants and entitlement grants are combined'); + + // Test: Permission aggregation (MAX) with overlapping grants + db.set_grant({ userIdentifier: 'demo@example.com', resourceName: 'corpus-research', level: 2 }); // Direct WRITE + const scope5 = db.get_user_scope('demo@example.com', ['urn:nbn:fi:lb-2022031701@LBR']); + assert(scope5.corpora['corpus-research'] === 2, 'Should use MAX permission (WRITE > READ)'); + console.log('✓ Overlapping permissions use MAX (highest wins)'); + + // Test: JIT user provisioning in JWT flow + const newUserId = db.ensure_user('newuser@kielipankki.fi'); + assert(typeof newUserId === 'number', 'ensure_user should return user ID'); + assert(db.user_exists('newuser@kielipankki.fi'), 'User should be created'); + + const newUserId2 = db.ensure_user('newuser@kielipankki.fi'); + assert(newUserId === newUserId2, 'ensure_user should be idempotent'); + console.log('✓ JIT user provisioning works correctly'); + + // Test: New user with entitlements but no direct grants + const newUserScope = db.get_user_scope('newuser@kielipankki.fi', ['urn:nbn:fi:lb-2022031701@LBR']); + assert(newUserScope.corpora['corpus-research'] === 1, 'New user should get entitlement permissions'); + console.log('✓ New users receive entitlement-based permissions'); + + // Test: Non-existent entitlement URN (should be ignored) + const scopeInvalid = db.get_user_scope('demo@example.com', [ + 'urn:nbn:fi:lb-2022031701@LBR', + 'urn:nbn:fi:lb-9999999999@LBR' // Non-existent + ]); + assert(scopeInvalid.corpora['corpus-research'] === 2, 'Should process valid entitlements'); + assert(!scopeInvalid.corpora['nonexistent'], 'Should ignore invalid entitlements'); + console.log('✓ Invalid entitlement URNs are silently ignored'); +} + +// ============================================================================ +// Run All Tests +// ============================================================================ + +function runAllTests() { + console.log('\n' + '='.repeat(70)); + console.log('Running Database Tests for korp-auth'); + console.log('='.repeat(70)); + + let failed = false; + + try { + testDatabaseSchema(); + testEntitlementManagement(); + testGrantManagement(); + testPermissionAggregation(); + testUserManagement(); + testResourceManagement(); + testJwtFlowIntegration(); + + console.log('\n' + '='.repeat(70)); + console.log('✓ All tests passed!'); + console.log('='.repeat(70) + '\n'); + + } catch (error) { + failed = true; + console.error('\n' + '='.repeat(70)); + console.error('✗ Test failed:', error.message); + console.error('='.repeat(70)); + console.error(error.stack); + console.error('\n'); + } finally { + teardownTestDatabase(); + } + + process.exit(failed ? 1 : 0); +} + +// Run tests if this file is executed directly +if (require.main === module) { + runAllTests(); +} + +module.exports = { runAllTests }; diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..cce9367 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,190 @@ +# Korp Auth Admin Tools + +This directory contains administrative tools for managing entitlements and grants in korp-auth. + +## korp-auth-admin.py + +Python CLI tool for managing entitlements and grants via the admin API. + +### Requirements + +- `requests` +- The admin API key + +### Configuration + +**API Key Priority:** + +The tool accepts the admin API key in three ways (in order of precedence): + +1. **From a file**: + ```bash + pass lb_passwords Kielipankki-proxy/korp-auth-api-key > korp-auth-key + ./korp-auth-admin.py --api-key-file korp-auth-key + ``` + +2. **From environment variable**: + ```bash + export KORP_AUTH_API_KEY="your-admin-api-key" + ./korp-auth-admin.py + ``` + +3. **From stdin** (if neither file nor env var is set): + ```bash + ./korp-auth-admin.py list + # Prompts: Enter admin API key + ``` + +**Server URL:** + +```bash +export KORP_AUTH_URL="https://kielipankki.fi/api/auth" # or use --url flag +``` + +### Commands + +#### Import from TSV file + +Import entitlements and grants from a tab-separated file in legacy format: + +```bash +./korp-auth-admin.py import entitlements.tsv +``` + +**TSV Format:** +``` +urn:nbn:fi:lb-201403261@LBR DMA +urn:nbn:fi:lb-201403261@LBR DMA_20160421 Special date version for some reason +urn:nbn:fi:lb-2014032621@LBR FSTC_FISC_LIT This is totally lit +``` + +Format: `URNRESOURCE_NAME[DESCRIPTION]` + +The description column is optional. If provided in the TSV file, it will be used for that entitlement. If not provided, the `--description` argument will be used as fallback. If neither is provided, the description will be empty. + +**Options:** +- `--level N` - Permission level for all grants (1=READ, 2=WRITE, 3=ADMIN, default: 1) +- `--description "text"` - Fallback description for entitlements without description in TSV (default: empty) +- `--continue-on-error` - Don't stop if some entitlements fail + +**Examples:** +```bash +# Import with READ permission (default) +./korp-auth-admin.py import legacy_dump.tsv + +# Import with WRITE permission +./korp-auth-admin.py import legacy_dump.tsv --level 2 + +# Import with fallback description (used for entries without TSV description) +./korp-auth-admin.py import legacy_dump.tsv --description "Migrated from legacy system 2025-01" + +# Import with 3-column TSV (descriptions in file take precedence over --description) +./korp-auth-admin.py import entitlements_with_descriptions.tsv + +# Continue processing even if some fail +./korp-auth-admin.py import legacy_dump.tsv --continue-on-error +``` + +#### List entitlements + +```bash +# Basic list (shows grant counts) +./korp-auth-admin.py list + +# Verbose list (shows all grants) +./korp-auth-admin.py list -v +``` + +#### Export to TSV + +```bash +# Export to stdout +./korp-auth-admin.py export + +# Export to file +./korp-auth-admin.py export -o entitlements.tsv +``` + +#### Add single entitlement + +```bash +./korp-auth-admin.py add-entitlement "urn:nbn:fi:lb-123@LBR" "Test Entitlement" +``` + +#### Add single grant + +```bash +# Add READ grant (level 1) +./korp-auth-admin.py add-grant "urn:nbn:fi:lb-123@LBR" corpus-name + +# Add WRITE grant (level 2) +./korp-auth-admin.py add-grant "urn:nbn:fi:lb-123@LBR" corpus-name --level 2 + +# Add ADMIN grant (level 3) +./korp-auth-admin.py add-grant "urn:nbn:fi:lb-123@LBR" corpus-name --level 3 +``` + +#### Delete entitlement + +```bash +# With confirmation prompt +./korp-auth-admin.py delete-entitlement "urn:nbn:fi:lb-123@LBR" + +# Skip confirmation +./korp-auth-admin.py delete-entitlement "urn:nbn:fi:lb-123@LBR" --force +``` + +#### Delete grant + +```bash +./korp-auth-admin.py delete-grant "urn:nbn:fi:lb-123@LBR" corpus-name +``` + +### Migration Workflow + +**From legacy MariaDB system:** + +1. Export from `korp-authdb.py lbr_map` to TSV + +2. Import TSV korp-auth: + ```bash + ./korp-auth-admin.py --api-key-file ~/korp-auth-key import korp_authdb_dump.tsv --level 1 --description "Migrated from korp-authdb" + ``` + +3. Verify: + ```bash + ./korp-auth-admin.py --api-key-file ~/korp-auth-key list -v | head -20 + ``` + +### Error Handling + +The tool provides clear error messages: + +- **401 Unauthorized**: Check your API key +- **404 Not Found**: Entitlement or resource doesn't exist +- **400 Bad Request**: Validation error (check your input) +- **500 Internal Server Error**: Server-side issue (check server logs) + +Use `--continue-on-error` with `import` to skip failed entries and continue processing. + +### TSV File Format Details + +**Supported:** +- Tab-separated values (URN, resource name, optional description) +- Empty lines (ignored) +- Comments starting with `#` (ignored) +- Multiple grants for same URN (will be grouped) +- Multiple URNs for same resource (each creates separate grant) +- Mixed 2-column and 3-column format (description column optional per-line) + +### Debugging + +Enable verbose output by running with DEBUG: + +```bash +# For the tool itself +python3 -u korp-auth-admin.py list -v + +# For the API server (if you control it) +DEBUG=korp-auth:admin npm start +``` diff --git a/tools/korp-auth-admin.py b/tools/korp-auth-admin.py new file mode 100755 index 0000000..757a4a6 --- /dev/null +++ b/tools/korp-auth-admin.py @@ -0,0 +1,646 @@ +#!/usr/bin/env python3 +""" +korp-auth admin tool + +Command-line tool for managing entitlements and grants in korp-auth. +Supports importing from TSV files and command-line options, makes +admin API operations (not database writes). +""" + +import argparse +import sys +import os +import requests +from typing import List, Tuple, Optional +from collections import defaultdict + + +class KorpAuthClient: + """Client for interacting with korp-auth admin API""" + + def __init__(self, base_url: str, api_key: str): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.headers = {"X-API-Key": api_key, "Content-Type": "application/json"} + + def list_entitlements(self) -> List[dict]: + """List all entitlements""" + response = requests.get( + f"{self.base_url}/admin/entitlements", headers=self.headers + ) + response.raise_for_status() + return response.json()["entitlements"] + + def get_entitlement(self, identifier: str) -> dict: + """Get single entitlement with grants""" + response = requests.get( + f'{self.base_url}/admin/entitlement/{requests.utils.quote(identifier, safe="")}', + headers=self.headers, + ) + response.raise_for_status() + return response.json() + + def create_entitlement( + self, identifier: str, description: str, grants: Optional[List[dict]] = None + ) -> dict: + """Create or update entitlement with optional grants""" + data = {"identifier": identifier, "description": description} + if grants: + data["grants"] = grants + + response = requests.post( + f"{self.base_url}/admin/entitlement", headers=self.headers, json=data + ) + response.raise_for_status() + return response.json() + + def delete_entitlement(self, identifier: str): + """Delete entitlement and all its grants""" + response = requests.delete( + f'{self.base_url}/admin/entitlement/{requests.utils.quote(identifier, safe="")}', + headers=self.headers, + ) + response.raise_for_status() + + def add_grant(self, entitlement_identifier: str, resource_name: str, level: int) -> dict: + """Add or update single grant""" + data = { + "entitlementIdentifier": entitlement_identifier, + "resourceName": resource_name, + "level": level, + } + response = requests.post( + f"{self.base_url}/admin/grant", headers=self.headers, json=data + ) + response.raise_for_status() + return response.json() + + def delete_grant(self, entitlement_identifier: str, resource_name: str): + """Delete single grant""" + params = {"entitlementIdentifier": entitlement_identifier, "resourceName": resource_name} + response = requests.delete( + f"{self.base_url}/admin/grant", headers=self.headers, params=params + ) + response.raise_for_status() + + def list_resources(self) -> List[dict]: + """List all resources""" + response = requests.get( + f"{self.base_url}/admin/resources", headers=self.headers + ) + response.raise_for_status() + return response.json()["resources"] + + def create_resource(self, name: str, resource_type: str) -> dict: + """Create a new resource""" + data = {"name": name, "type": resource_type} + response = requests.post( + f"{self.base_url}/admin/resource", headers=self.headers, json=data + ) + response.raise_for_status() + return response.json() + + def delete_resource(self, name: str): + """Delete a resource and all its grants""" + response = requests.delete( + f'{self.base_url}/admin/resource/{requests.utils.quote(name, safe="")}', + headers=self.headers, + ) + response.raise_for_status() + + +def parse_tsv_line(line: str) -> Optional[Tuple[str, str, str]]: + """ + Parse a TSV line in format: IDENTIFIERRESOURCE_NAME[DESCRIPTION] + + Returns: (identifier, resource_name, description) or None if line should be skipped + Description is empty string if not provided (third column optional) + """ + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith("#"): + return None + + # Split by tab + parts = line.split("\t") + if len(parts) < 2 or len(parts) > 3: + print( + f"Warning: Skipping malformed line (expected 2-3 tab-separated fields): {line}", + file=sys.stderr, + ) + return None + + identifier = parts[0].strip() + resource_name = parts[1].strip() + description = parts[2].strip() if len(parts) >= 3 else "" + + if not identifier or not resource_name: + print( + f"Warning: Skipping line with empty identifier or resource name: {line}", + file=sys.stderr, + ) + return None + + return (identifier, resource_name, description) + + +def read_tsv_file(filepath: str) -> dict: + """ + Read TSV file and group grants by entitlement identifier + + Returns: dict mapping identifier -> (resource_list, description) + If same identifier appears multiple times, keeps first non-empty description + """ + grants_by_identifier = defaultdict(lambda: ([], "")) + + with open(filepath, "r", encoding="utf-8") as f: + for line_num, line in enumerate(f, 1): + result = parse_tsv_line(line) + if result: + identifier, resource_name, description = result + resources, existing_desc = grants_by_identifier[identifier] + resources.append(resource_name) + # Keep first non-empty description + if description and not existing_desc: + grants_by_identifier[identifier] = (resources, description) + else: + grants_by_identifier[identifier] = (resources, existing_desc) + + return dict(grants_by_identifier) + + +def cmd_import(args, client: KorpAuthClient): + """Import entitlements and grants from TSV file""" + print(f"Reading from: {args.file}") + + try: + grants_by_identifier = read_tsv_file(args.file) + except FileNotFoundError: + print(f"Error: File not found: {args.file}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error reading file: {e}", file=sys.stderr) + return 1 + + print(f"Found {len(grants_by_identifier)} unique entitlements") + + # Default permission level + level = args.level + + success_count = 0 + error_count = 0 + + for identifier, (resources, tsv_description) in grants_by_identifier.items(): + print(f"\nProcessing: {identifier} ({len(resources)} grants)") + + # Use TSV description if provided, otherwise fall back to --description argument, otherwise empty + description = tsv_description or args.description or "" + + # Prepare grants + grants = [{"resourceName": res, "level": level} for res in resources] + + try: + result = client.create_entitlement(identifier, description, grants) + status = "Created" if result["created"] else "Updated" + print(f" ✓ {status} entitlement with {result['grantsSet']} grants") + success_count += 1 + except requests.exceptions.HTTPError as e: + print( + f" ✗ Error: {e.response.status_code} - {e.response.text}", + file=sys.stderr, + ) + error_count += 1 + if not args.continue_on_error: + return 1 + except Exception as e: + print(f" ✗ Error: {e}", file=sys.stderr) + error_count += 1 + if not args.continue_on_error: + return 1 + + print(f"\n{'='*60}") + print(f"Summary: {success_count} succeeded, {error_count} failed") + print(f"{'='*60}") + + return 0 if error_count == 0 else 1 + + +def cmd_list(args, client: KorpAuthClient): + """List entitlements""" + try: + entitlements = client.list_entitlements() + except requests.exceptions.HTTPError as e: + print(f"Error: {e.response.status_code} - {e.response.text}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + if not entitlements: + print("No entitlements found") + return 0 + + print(f"Found {len(entitlements)} entitlements:\n") + + for ent in entitlements: + print(f"Identifier: {ent['identifier']}") + print(f" Description: {ent['description']}") + print(f" Grants: {ent['grant_count']}") + print(f" Created: {ent['created_at']}") + + if args.verbose: + # Fetch full details including grants + try: + details = client.get_entitlement(ent["identifier"]) + if details["grants"]: + print(f" Resources:") + for grant in details["grants"]: + level_name = {1: "READ", 2: "WRITE", 3: "ADMIN"}.get( + grant["permission_level"], "?" + ) + print(f" - {grant['resource_name']} ({level_name})") + except Exception as e: + print(f" (Could not fetch grant details: {e})", file=sys.stderr) + + print() + + return 0 + + +def cmd_export(args, client: KorpAuthClient): + """Export entitlements to TSV format""" + try: + entitlements = client.list_entitlements() + except requests.exceptions.HTTPError as e: + print(f"Error: {e.response.status_code} - {e.response.text}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + lines = [] + + for ent in entitlements: + # Fetch full details to get grants + try: + details = client.get_entitlement(ent["identifier"]) + for grant in details["grants"]: + lines.append(f"{ent['identifier']}\t{grant['resource_name']}") + except Exception as e: + print( + f"Warning: Could not fetch grants for {ent['identifier']}: {e}", + file=sys.stderr, + ) + + output = "\n".join(lines) + + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(output) + f.write("\n") + print(f"Exported {len(lines)} grants to: {args.output}") + else: + print(output) + + return 0 + + +def cmd_add_entitlement(args, client: KorpAuthClient): + """Add or update single entitlement""" + try: + result = client.create_entitlement(args.identifier, args.description) + status = "Created" if result["created"] else "Updated" + print(f"✓ {status} entitlement: {result['identifier']}") + return 0 + except requests.exceptions.HTTPError as e: + print(f"Error: {e.response.status_code} - {e.response.text}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +def cmd_add_grant(args, client: KorpAuthClient): + """Add or update single grant""" + try: + result = client.add_grant(args.identifier, args.resource, args.level) + print( + f"✓ Added grant: {result['entitlementIdentifier']} -> {result['resourceName']} (level {result['level']})" + ) + return 0 + except requests.exceptions.HTTPError as e: + print(f"Error: {e.response.status_code} - {e.response.text}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +def cmd_delete_entitlement(args, client: KorpAuthClient): + """Delete entitlement""" + if not args.force: + confirm = input(f"Delete entitlement '{args.identifier}' and all its grants? [y/N]: ") + if confirm.lower() != "y": + print("Cancelled") + return 0 + + try: + client.delete_entitlement(args.identifier) + print(f"✓ Deleted entitlement: {args.identifier}") + return 0 + except requests.exceptions.HTTPError as e: + print(f"Error: {e.response.status_code} - {e.response.text}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +def cmd_delete_grant(args, client: KorpAuthClient): + """Delete single grant""" + try: + client.delete_grant(args.identifier, args.resource) + print(f"✓ Deleted grant: {args.identifier} -> {args.resource}") + return 0 + except requests.exceptions.HTTPError as e: + print(f"Error: {e.response.status_code} - {e.response.text}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +def cmd_list_resources(args, client: KorpAuthClient): + """List resources""" + try: + resources = client.list_resources() + except requests.exceptions.HTTPError as e: + print(f"Error: {e.response.status_code} - {e.response.text}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + if not resources: + print("No resources found") + return 0 + + print(f"Found {len(resources)} resources:\n") + + for res in resources: + print(f"Name: {res['resource_name']}") + print(f" Type: {res['type']}") + print(f" Grants: {res['grant_count']}") + print() + + return 0 + + +def cmd_add_resource(args, client: KorpAuthClient): + """Add a new resource""" + try: + result = client.create_resource(args.name, args.type) + print(f"✓ Created resource: {result['name']} (type: {result['type']})") + return 0 + except requests.exceptions.HTTPError as e: + if e.response.status_code == 409: + print(f"Error: Resource '{args.name}' already exists", file=sys.stderr) + else: + print( + f"Error: {e.response.status_code} - {e.response.text}", file=sys.stderr + ) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +def cmd_delete_resource(args, client: KorpAuthClient): + """Delete resource""" + if not args.force: + confirm = input( + f"Delete resource '{args.name}' and all its grants? [y/N]: " + ) + if confirm.lower() != "y": + print("Cancelled") + return 0 + + try: + client.delete_resource(args.name) + print(f"✓ Deleted resource: {args.name}") + return 0 + except requests.exceptions.HTTPError as e: + print(f"Error: {e.response.status_code} - {e.response.text}", file=sys.stderr) + return 1 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + + +def main(): + parser = argparse.ArgumentParser( + description="Korp Auth Admin Tool - Manage entitlements and grants", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Import from TSV file + %(prog)s import entitlements.tsv + + # List all entitlements + %(prog)s list + + # List with full grant details + %(prog)s list -v + + # Export to TSV + %(prog)s export -o output.tsv + + # Add single entitlement + %(prog)s add-entitlement "urn:nbn:fi:lb-123@LBR" "Test Entitlement" + + # Add single grant + %(prog)s add-grant "urn:nbn:fi:lb-123@LBR" corpus-name --level 1 + + # Delete entitlement + %(prog)s delete-entitlement "urn:nbn:fi:lb-123@LBR" + + # List all resources + %(prog)s list-resources + + # Add a new resource (corpus by default) + %(prog)s add-resource my-corpus + + # Add a metadata resource + %(prog)s add-resource my-metadata --type metadata + + # Delete a resource + %(prog)s delete-resource my-corpus + +Environment variables: + KORP_AUTH_URL Base URL for korp-auth service (default: http://localhost) + KORP_AUTH_API_KEY Admin API key (used if --api-key-file not provided) + +API Key Priority: + 1. --api-key-file (if specified) + 2. KORP_AUTH_API_KEY environment variable + 3. Prompt from stdin (if neither of the above is set) + """, + ) + + # Global options + parser.add_argument( + "--url", + default=os.getenv("KORP_AUTH_URL", "http://localhost"), + help="Base URL for korp-auth service", + ) + parser.add_argument("--api-key-file", help="Path to file containing admin API key") + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Import command + import_parser = subparsers.add_parser("import", help="Import from TSV file") + import_parser.add_argument( + "file", help="TSV file to import (format: URNRESOURCE_NAME)" + ) + import_parser.add_argument( + "--level", + type=int, + default=1, + choices=[1, 2, 3], + help="Permission level (1=READ, 2=WRITE, 3=ADMIN, default: 1)", + ) + import_parser.add_argument( + "--description", help="Description for entitlements (default: auto-generated)" + ) + import_parser.add_argument( + "--continue-on-error", + action="store_true", + help="Continue processing even if some entitlements fail", + ) + + # List command + list_parser = subparsers.add_parser("list", help="List entitlements") + list_parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Show full grant details for each entitlement", + ) + + # Export command + export_parser = subparsers.add_parser("export", help="Export to TSV format") + export_parser.add_argument("-o", "--output", help="Output file (default: stdout)") + + # Add entitlement command + add_ent_parser = subparsers.add_parser( + "add-entitlement", help="Add or update entitlement" + ) + add_ent_parser.add_argument("identifier", help="Entitlement identifier (e.g. URN)") + add_ent_parser.add_argument("description", help="Description") + + # Add grant command + add_grant_parser = subparsers.add_parser("add-grant", help="Add or update grant") + add_grant_parser.add_argument("identifier", help="Entitlement identifier (e.g. URN)") + add_grant_parser.add_argument("resource", help="Resource name") + add_grant_parser.add_argument( + "--level", + type=int, + default=1, + choices=[1, 2, 3], + help="Permission level (1=READ, 2=WRITE, 3=ADMIN, default: 1)", + ) + + # Delete entitlement command + del_ent_parser = subparsers.add_parser( + "delete-entitlement", help="Delete entitlement" + ) + del_ent_parser.add_argument("identifier", help="Entitlement identifier (e.g. URN)") + del_ent_parser.add_argument( + "-f", "--force", action="store_true", help="Skip confirmation prompt" + ) + + # Delete grant command + del_grant_parser = subparsers.add_parser("delete-grant", help="Delete grant") + del_grant_parser.add_argument("identifier", help="Entitlement identifier (e.g. URN)") + del_grant_parser.add_argument("resource", help="Resource name") + + # List resources command + subparsers.add_parser("list-resources", help="List all resources") + + # Add resource command + add_res_parser = subparsers.add_parser("add-resource", help="Add a new resource") + add_res_parser.add_argument("name", help="Resource name") + add_res_parser.add_argument( + "--type", + default="corpus", + choices=["corpus", "metadata", "other"], + help="Resource type (default: corpus)", + ) + + # Delete resource command + del_res_parser = subparsers.add_parser("delete-resource", help="Delete resource") + del_res_parser.add_argument("name", help="Resource name") + del_res_parser.add_argument( + "-f", "--force", action="store_true", help="Skip confirmation prompt" + ) + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return 1 + + # Get API key with priority: 1) --api-key-file, 2) env var, 3) stdin + api_key = None + + # 1. Try API key file (explicit argument takes precedence) + if args.api_key_file: + try: + with open(args.api_key_file, "r") as f: + api_key = f.read().strip() + except FileNotFoundError: + print( + f"Error: API key file not found: {args.api_key_file}", file=sys.stderr + ) + return 1 + except Exception as e: + print(f"Error reading API key file: {e}", file=sys.stderr) + return 1 + + # 2. Try environment variable + elif os.getenv("KORP_AUTH_API_KEY"): + api_key = os.getenv("KORP_AUTH_API_KEY") + + # 3. Prompt user to enter from stdin + else: + print("Enter admin API key: ", file=sys.stderr, end="", flush=True) + api_key = input().strip() + + # Validate API key + if not api_key: + print("Error: API key cannot be empty", file=sys.stderr) + return 1 + + # Create client + client = KorpAuthClient(args.url, api_key) + + # Dispatch to command handler + commands = { + "import": cmd_import, + "list": cmd_list, + "export": cmd_export, + "add-entitlement": cmd_add_entitlement, + "add-grant": cmd_add_grant, + "delete-entitlement": cmd_delete_entitlement, + "delete-grant": cmd_delete_grant, + "list-resources": cmd_list_resources, + "add-resource": cmd_add_resource, + "delete-resource": cmd_delete_resource, + } + + return commands[args.command](args, client) + + +if __name__ == "__main__": + sys.exit(main())