diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b9dfa775..145609a1 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -19,9 +19,9 @@ jobs: fetch-depth: 0 - name: Set npm token env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.BOT_TOKEN }} run: | - echo "@linode:registry=https://npm.pkg.github.com/linode" > .npmrc + echo "@linode:registry=https://npm.pkg.github.com" > .npmrc echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> .npmrc echo '::set-output name=diff::1' diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5be1c5e7..925689d3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,6 +2,7 @@ # Required repository/environment secrets: # OCI_USERNAME - registry login username # DOCKERHUB_LINODEBOT_TOKEN - registry login token +# BOT_TOKEN - GitHub token with package read access, for npm install of @linode/*-client-grpc packages from GitHub Packages name: Build test push release on: pull_request: @@ -77,6 +78,8 @@ jobs: tags: ${{ env.REGISTRY }}/${{ env.REPO }}:${{ steps.set_tag.outputs.tag }} cache-from: type=gha cache-to: type=gha,mode=max + secrets: | + NPM_TOKEN=${{ secrets.BOT_TOKEN }} - name: Build, test if: ${{ env.SHOULD_PUSH != 'true' }} uses: docker/build-push-action@v7 @@ -85,3 +88,5 @@ jobs: context: . tags: not-used:tmp cache-from: type=gha + secrets: | + NPM_TOKEN=${{ secrets.BOT_TOKEN }} diff --git a/.github/workflows/release-software.yml b/.github/workflows/release-software.yml index 21c50509..9b7dc5a1 100644 --- a/.github/workflows/release-software.yml +++ b/.github/workflows/release-software.yml @@ -1,6 +1,7 @@ # Required repository/environment secrets: # OCI_USERNAME - registry login username # DOCKERHUB_LINODEBOT_TOKEN - registry login token +# BOT_TOKEN - GitHub token with package read access, for npm install of @linode/*-client-grpc packages from GitHub Packages name: Release Software on: @@ -110,6 +111,8 @@ jobs: build-args: | VERSION=${{ env.GIT_TAG }} tags: ${{ env.CONTAINER_IMAGE_TAG }} + secrets: | + NPM_TOKEN=${{ secrets.BOT_TOKEN }} - name: Create GitHub Release uses: linode/apl-gh-actions/actions/release-create-github-release@v0.4.0 @@ -124,6 +127,13 @@ jobs: with: node-version: 24 + - name: Set npm token + env: + NODE_AUTH_TOKEN: ${{ secrets.BOT_TOKEN }} + run: | + echo "@linode:registry=https://npm.pkg.github.com" >> .npmrc + echo "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> .npmrc + - name: Build OpenAPI schema run: | npm ci diff --git a/.npmrc b/.npmrc index 74b80ab3..77419916 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ # The redkubes at github packages is a proxy for all npm packages. -@redkubes:registry=https://npm.pkg.github.com/redkubes +@linode:registry=https://npm.pkg.github.com engine-strict=true diff --git a/Dockerfile b/Dockerfile index 671922a0..18fd9f14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,11 +9,16 @@ RUN mkdir /app WORKDIR /app # Install dependencies before copying the full source code to take advantage of Docker layer caching -COPY package*.json ./ +COPY package*.json .npmrc ./ # Needed for postinstall (build:models) during npm ci COPY src/build-spec.ts ./src/build-spec.ts COPY src/openapi ./src/openapi -RUN npm ci +# @linode/dex-client-grpc is hosted on GitHub Packages, which requires auth even for public pkg. +RUN --mount=type=secret,id=NPM_TOKEN \ + cp .npmrc .npmrc.orig && \ + echo "//npm.pkg.github.com/:_authToken=$(cat /run/secrets/NPM_TOKEN)" >> .npmrc && \ + npm ci && \ + mv .npmrc.orig .npmrc COPY . .* ./ RUN npm run build diff --git a/package-lock.json b/package-lock.json index 9e499f47..9ac5ab28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,27 @@ { - "name": "@redkubes/otomi-api", + "name": "@linode/apl-api", "version": "5.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@redkubes/otomi-api", + "name": "@linode/apl-api", "version": "5.1.0", "hasInstallScript": true, "license": "ISC", "dependencies": { "@apidevtools/json-schema-ref-parser": "16.0.3", "@casl/ability": "6.8.1", + "@grpc/grpc-js": "^1.14.4", "@kubernetes/client-node": "1.4.0", "@linode/api-v4": "0.158.0", + "@linode/dex-client-grpc": "0.1.0", "@linode/kubeseal-encrypt": "^1.0.1", "@types/json-schema": "7.0.15", "@types/jsonwebtoken": "9.0.10", "async-retry": "^1.3.3", "axios": "1.20.0", + "bcryptjs": "^3.0.3", "clean-deep": "3.4.0", "cors": "2.8.6", "debug": "4.4.3", @@ -31,7 +34,6 @@ "generate-password": "1.7.1", "glob": "13.0.6", "jose": "^6.2.12", - "jsonpath": "1.3.0", "jsonwebtoken": "9.0.3", "jwt-decode": "4.0.0", "lightship": "6.7.2", @@ -52,6 +54,7 @@ "@eslint/compat": "2.1.1", "@redocly/openapi-cli": "1.0.0-beta.95", "@types/async-retry": "^1.4.8", + "@types/bcryptjs": "^2.4.6", "@types/debug": "^4.1.13", "@types/expect": "24.3.2", "@types/express": "^5.0.6", @@ -4906,6 +4909,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@bufbuild/protobuf": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -6249,6 +6258,37 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -6920,6 +6960,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@jsdevtools/ono": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", @@ -7069,6 +7119,17 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/@linode/dex-client-grpc": { + "version": "0.1.0", + "resolved": "https://npm.pkg.github.com/download/@linode/dex-client-grpc/0.1.0/86476ca8abee2a4fbbb92db589384b680b219337", + "integrity": "sha512-3g8Ut/acqADXuIW5FTVerQSiWMly9DrjydgLS8CuoVbgk/WUZUo5sjmzeGCjkyEIw2OKZVsaIdYo1Miwh1mFxg==", + "dependencies": { + "@bufbuild/protobuf": "^2.14.1" + }, + "peerDependencies": { + "@grpc/grpc-js": "^1.14.4" + } + }, "node_modules/@linode/kubeseal-encrypt": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@linode/kubeseal-encrypt/-/kubeseal-encrypt-1.0.1.tgz", @@ -7563,6 +7624,63 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@redocly/ajv": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.3.tgz", @@ -7937,6 +8055,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -9128,7 +9253,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -9137,7 +9261,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -9573,6 +9696,15 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -10063,7 +10195,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10074,8 +10205,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/colorette": { "version": "1.4.0", @@ -10908,7 +11038,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/empathic": { @@ -11243,7 +11372,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11267,27 +11395,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "license": "BSD-2-Clause", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, "node_modules/eslint": { "version": "10.11.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", @@ -11525,6 +11632,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -11563,6 +11671,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -11572,6 +11681,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -12370,7 +12480,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -13220,7 +13329,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -14512,17 +14620,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsonpath": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.3.0.tgz", - "integrity": "sha512-0kjkYHJBkAy50Z5QzArZ7udmvxrJzkpKYW27fiF//BrMY7TQibYLl+FYIXN2BiYmwMIVzSfD8aDRj6IzgBX2/w==", - "license": "MIT", - "dependencies": { - "esprima": "1.2.5", - "static-eval": "2.1.1", - "underscore": "1.13.6" - } - }, "node_modules/jsonpath-plus": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", @@ -14541,18 +14638,6 @@ "node": ">=18.0.0" } }, - "node_modules/jsonpath/node_modules/esprima": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-1.2.5.tgz", - "integrity": "sha512-S9VbPDU0adFErpDai3qDkjq8+G05ONtKzcyNrPKg/ZKa+tf879nX2KexNU95b31UoTJjRLInNBHHHjFPoCd7lQ==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -15026,6 +15111,12 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -15116,6 +15207,12 @@ "resolved": "https://registry.npmjs.org/lodash.transform/-/lodash.transform-4.6.0.tgz", "integrity": "sha1-EjBkIvYzJK7YSD0/ODMrX2cFR6A=" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru_map": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.3.3.tgz", @@ -16625,6 +16722,29 @@ "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -17017,7 +17137,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -17758,7 +17877,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "devOptional": true, + "dev": true, "engines": { "node": ">=0.10.0" } @@ -17824,15 +17943,6 @@ "node": ">=8" } }, - "node_modules/static-eval": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", - "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", - "license": "MIT", - "dependencies": { - "escodegen": "^2.1.0" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -17931,7 +18041,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -18037,7 +18146,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -18969,12 +19077,6 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, - "node_modules/underscore": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", - "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", - "license": "MIT" - }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -19394,7 +19496,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -19503,7 +19604,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "engines": { "node": ">=10" } @@ -19533,7 +19633,6 @@ "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -19561,7 +19660,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -19576,7 +19674,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index 29439af3..bce3a431 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,21 @@ { "author": "RedKubes", "bugs": { - "url": "https://github.com/redkubes/otomi-api/issues" + "url": "https://github.com/linode/apl-api/issues" }, "dependencies": { "@apidevtools/json-schema-ref-parser": "16.0.3", "@casl/ability": "6.8.1", + "@grpc/grpc-js": "^1.14.4", "@kubernetes/client-node": "1.4.0", "@linode/api-v4": "0.158.0", + "@linode/dex-client-grpc": "0.1.0", "@linode/kubeseal-encrypt": "^1.0.1", "@types/json-schema": "7.0.15", "@types/jsonwebtoken": "9.0.10", "async-retry": "^1.3.3", "axios": "1.20.0", + "bcryptjs": "^3.0.3", "clean-deep": "3.4.0", "cors": "2.8.6", "debug": "4.4.3", @@ -25,7 +28,6 @@ "generate-password": "1.7.1", "glob": "13.0.6", "jose": "^6.2.12", - "jsonpath": "1.3.0", "jsonwebtoken": "9.0.3", "jwt-decode": "4.0.0", "lightship": "6.7.2", @@ -47,6 +49,7 @@ "@eslint/compat": "2.1.1", "@redocly/openapi-cli": "1.0.0-beta.95", "@types/async-retry": "^1.4.8", + "@types/bcryptjs": "^2.4.6", "@types/debug": "^4.1.13", "@types/expect": "24.3.2", "@types/express": "^5.0.6", @@ -94,7 +97,7 @@ "node": ">=24 <25" }, "engineStrict": true, - "homepage": "https://github.com/redkubes/otomi-api#readme", + "homepage": "https://github.com/linode/apl-api#readme", "license": "ISC", "overrides": { "@babel/core": "$@babel/core" @@ -105,14 +108,14 @@ ] }, "main": "dist/src/app.js", - "name": "@redkubes/otomi-api", + "name": "@linode/apl-api", "publishConfig": { "private": true, "registry": "https://npm.pkg.github.com" }, "repository": { "type": "git", - "url": "git+https://github.com/redkubes/otomi-api.git" + "url": "git+https://github.com/linode/apl-api.git" }, "scripts": { "build": "npm run clean && npm run build:models && tsc && tsc-alias --dir dist -v && copyup --error src/generated-schema.json src/values-schema.yaml src/ttyManifests/*.yaml src/ttyManifests/adminTtyManifests/*.yaml dist/src && copyup --error ./src/license/license.pem ./dist/src", diff --git a/src/api.authz.test.ts b/src/api.authz.test.ts index d72003ef..5fb4260d 100644 --- a/src/api.authz.test.ts +++ b/src/api.authz.test.ts @@ -292,7 +292,7 @@ describe('API authz tests', () => { await agent .put(`/v1/teams/${teamId}/users`) - .send([{ ...userData }]) + .send([{ id: 'user1', teams: ['team1'] }]) .set('Authorization', `Bearer ${teamAdminToken}`) .expect(200) }) diff --git a/src/clients/dexClient.test.ts b/src/clients/dexClient.test.ts new file mode 100644 index 00000000..bb2903fb --- /dev/null +++ b/src/clients/dexClient.test.ts @@ -0,0 +1,132 @@ +const mockCreatePassword = jest.fn() +const mockUpdatePassword = jest.fn() +const mockDeletePassword = jest.fn() + +jest.mock('@linode/dex-client-grpc', () => ({ + DexClient: jest.fn().mockImplementation(() => ({ + createPassword: mockCreatePassword, + updatePassword: mockUpdatePassword, + deletePassword: mockDeletePassword, + })), +})) + +// dexClient.ts reads DEX_GRPC_ADDRESS at module load via cleanEnv(); override it here so the +// value doesn't depend on process.env assignment order relative to this file's own imports. +jest.mock('src/validators', () => ({ + ...jest.requireActual('src/validators'), + cleanEnv: (validators: Record) => ({ + ...jest.requireActual('src/validators').cleanEnv(validators), + DEX_GRPC_ADDRESS: 'localhost:5557', + }), +})) + +import { DEX_NO_GROUPS_SENTINEL } from './dexConstants' +import { AlreadyExists, NotExistError } from 'src/error' +import { createDexPassword, deleteDexPassword, DexProvisionError, updateDexPassword } from './dexClient' + +describe('dexClient', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('createDexPassword sends email, hash, username, user_id and groups', async () => { + mockCreatePassword.mockImplementation((_req, cb) => cb(null, { alreadyExists: false })) + + await createDexPassword({ + id: 'uuid-1', + email: 'a@b.com', + passwordHash: '$2a$10$hash', + username: 'a', + groups: ['platform-admin'], + }) + + expect(mockCreatePassword).toHaveBeenCalledWith( + { + password: { + email: 'a@b.com', + hash: Buffer.from('$2a$10$hash', 'utf-8'), + username: 'a', + userId: 'uuid-1', + groups: ['platform-admin'], + }, + }, + expect.any(Function), + ) + }) + + it('createDexPassword sends the no-groups sentinel instead of an empty array', async () => { + mockCreatePassword.mockImplementation((_req, cb) => cb(null, { alreadyExists: false })) + + await createDexPassword({ id: 'uuid-1', email: 'a@b.com', passwordHash: 'h', username: 'a', groups: [] }) + + expect(mockCreatePassword).toHaveBeenCalledWith( + expect.objectContaining({ password: expect.objectContaining({ groups: [DEX_NO_GROUPS_SENTINEL] }) }), + expect.any(Function), + ) + }) + + it('createDexPassword rejects with DexProvisionError on RPC error', async () => { + mockCreatePassword.mockImplementation((_req, cb) => cb(new Error('unavailable'), null)) + + await expect( + createDexPassword({ id: 'uuid-1', email: 'a@b.com', passwordHash: 'h', username: 'a', groups: [] }), + ).rejects.toBeInstanceOf(DexProvisionError) + }) + + it('createDexPassword rejects with AlreadyExists (409) when Dex reports alreadyExists', async () => { + mockCreatePassword.mockImplementation((_req, cb) => cb(null, { alreadyExists: true })) + + await expect( + createDexPassword({ id: 'uuid-1', email: 'a@b.com', passwordHash: 'h', username: 'a', groups: [] }), + ).rejects.toBeInstanceOf(AlreadyExists) + }) + + it('updateDexPassword only sets provided fields', async () => { + mockUpdatePassword.mockImplementation((_req, cb) => cb(null, { notFound: false })) + + await updateDexPassword({ email: 'a@b.com', newGroups: ['team-blue'] }) + + expect(mockUpdatePassword).toHaveBeenCalledWith( + { email: 'a@b.com', newHash: Buffer.alloc(0), newUsername: '', newGroups: ['team-blue'] }, + expect.any(Function), + ) + }) + + it('updateDexPassword sends the no-groups sentinel when clearing groups to empty', async () => { + mockUpdatePassword.mockImplementation((_req, cb) => cb(null, { notFound: false })) + + await updateDexPassword({ email: 'a@b.com', newGroups: [] }) + + expect(mockUpdatePassword).toHaveBeenCalledWith( + expect.objectContaining({ newGroups: [DEX_NO_GROUPS_SENTINEL] }), + expect.any(Function), + ) + }) + + it('updateDexPassword leaves newGroups empty when groups are not being touched', async () => { + mockUpdatePassword.mockImplementation((_req, cb) => cb(null, { notFound: false })) + + await updateDexPassword({ email: 'a@b.com', newUsername: 'a' }) + + expect(mockUpdatePassword).toHaveBeenCalledWith(expect.objectContaining({ newGroups: [] }), expect.any(Function)) + }) + + it('updateDexPassword rejects with NotExistError (404) when the record is not found', async () => { + mockUpdatePassword.mockImplementation((_req, cb) => cb(null, { notFound: true })) + + await expect(updateDexPassword({ email: 'ghost@b.com' })).rejects.toBeInstanceOf(NotExistError) + }) + + it('deleteDexPassword resolves even when Dex reports not found (idempotent)', async () => { + mockDeletePassword.mockImplementation((_req, cb) => cb(null, { notFound: true })) + + await expect(deleteDexPassword('a@b.com')).resolves.toBeUndefined() + expect(mockDeletePassword).toHaveBeenCalledWith({ email: 'a@b.com' }, expect.any(Function)) + }) + + it('deleteDexPassword rejects with DexProvisionError on RPC error', async () => { + mockDeletePassword.mockImplementation((_req, cb) => cb(new Error('unavailable'), null)) + + await expect(deleteDexPassword('a@b.com')).rejects.toBeInstanceOf(DexProvisionError) + }) +}) diff --git a/src/clients/dexClient.ts b/src/clients/dexClient.ts new file mode 100644 index 00000000..12861605 --- /dev/null +++ b/src/clients/dexClient.ts @@ -0,0 +1,171 @@ +import { ChannelCredentials, ServiceError } from '@grpc/grpc-js' +import { + CreatePasswordResp, + DeletePasswordResp, + DeleteUserIdentityResp, + DexClient, + Password, + UpdatePasswordResp, + UserIdentity, +} from '@linode/dex-client-grpc' +import { cleanEnv, DEX_GRPC_ADDRESS } from 'src/validators' +import { DEX_NO_GROUPS_SENTINEL } from 'src/clients/dexConstants' +import { AlreadyExists, NotExistError } from 'src/error' + +export type { Password } + +const env = cleanEnv({ DEX_GRPC_ADDRESS }) + +function toDexGroups(groups: string[]): string[] { + return groups.length > 0 ? groups : [DEX_NO_GROUPS_SENTINEL] +} + +export class DexProvisionError extends Error { + constructor( + message: string, + public readonly cause?: unknown, + ) { + super(message) + this.name = 'DexProvisionError' + } +} + +let client: DexClient | undefined + +function getDexClient(): DexClient { + if (!env.DEX_GRPC_ADDRESS) { + throw new DexProvisionError('DEX_GRPC_ADDRESS must be set when AUTH_PROVIDER=dex') + } + if (!client) { + // Secured by Istio mtls and Authorization policy + client = new DexClient(env.DEX_GRPC_ADDRESS, ChannelCredentials.createInsecure()) + } + return client +} + +export interface CreateDexPasswordInput { + id: string + email: string + passwordHash: string + username: string + groups: string[] +} + +export async function createDexPassword(input: CreateDexPasswordInput): Promise { + const dex = getDexClient() + await new Promise((resolve, reject) => { + dex.createPassword( + { + password: { + email: input.email, + hash: Buffer.from(input.passwordHash, 'utf-8'), + username: input.username, + userId: input.id, + groups: toDexGroups(input.groups), + }, + }, + (err: ServiceError | null, resp: CreatePasswordResp) => { + if (err) { + reject(new DexProvisionError(`Dex CreatePassword failed for ${input.email}`, err)) + return + } + if (resp?.alreadyExists) { + reject(new AlreadyExists(`Dex already has a password record for ${input.email}`)) + return + } + resolve() + }, + ) + }) +} + +export interface UpdateDexPasswordInput { + email: string + newHash?: string + newUsername?: string + newGroups?: string[] +} + +export async function updateDexPassword(input: UpdateDexPasswordInput): Promise { + const dex = getDexClient() + await new Promise((resolve, reject) => { + dex.updatePassword( + { + email: input.email, + newHash: input.newHash ? Buffer.from(input.newHash, 'utf-8') : Buffer.alloc(0), + newUsername: input.newUsername ?? '', + newGroups: input.newGroups !== undefined ? toDexGroups(input.newGroups) : [], + }, + (err: ServiceError | null, resp: UpdatePasswordResp) => { + if (err) { + reject(new DexProvisionError(`Dex UpdatePassword failed for ${input.email}`, err)) + return + } + if (resp?.notFound) { + reject(new NotExistError(`Dex has no password record for ${input.email}`)) + return + } + resolve() + }, + ) + }) +} + +export async function listDexPasswords(): Promise { + const dex = getDexClient() + return new Promise((resolve, reject) => { + dex.listPasswords({}, (err: ServiceError | null, resp: { passwords: Password[] }) => { + if (err) { + reject(new DexProvisionError('Dex ListPasswords failed', err)) + return + } + resolve(resp?.passwords ?? []) + }) + }) +} + +export async function deleteDexPassword(email: string): Promise { + const dex = getDexClient() + await new Promise((resolve, reject) => { + dex.deletePassword({ email }, (err: ServiceError | null, resp: DeletePasswordResp) => { + if (err) { + reject(new DexProvisionError(`Dex DeletePassword failed for ${email}`, err)) + return + } + // notFound is treated as success: deleting an already-absent record is a no-op, + // matching deleteUser's existing idempotent-delete behavior for the Git side. + void resp + resolve() + }) + }) +} + +export async function listUserIdentitiesByUserId(userId: string): Promise { + const dex = getDexClient() + const identities = await new Promise((resolve, reject) => { + dex.listUserIdentities({}, (err: ServiceError | null, resp: { identities: UserIdentity[] }) => { + if (err) { + reject(new DexProvisionError('Dex ListUserIdentities failed', err)) + return + } + resolve(resp?.identities ?? []) + }) + }) + return identities.filter((identity) => identity.userId === userId) +} + +// Cascades to the identity's auth session, refresh/offline sessions, its password record, and +// the identity itself (see DeleteUserIdentityReq in @linode/dex-client-grpc's source .proto). +export async function deleteDexUserIdentity(userId: string, connectorId: string): Promise { + const dex = getDexClient() + await new Promise((resolve, reject) => { + dex.deleteUserIdentity({ userId, connectorId }, (err: ServiceError | null, resp: DeleteUserIdentityResp) => { + if (err) { + reject(new DexProvisionError(`Dex DeleteUserIdentity failed for ${userId}/${connectorId}`, err)) + return + } + void resp + resolve() + }) + }) +} diff --git a/src/clients/dexConstants.ts b/src/clients/dexConstants.ts new file mode 100644 index 00000000..868f8d2e --- /dev/null +++ b/src/clients/dexConstants.ts @@ -0,0 +1 @@ +export const DEX_NO_GROUPS_SENTINEL = '__no_groups__' diff --git a/src/middleware/jwt.test.ts b/src/middleware/jwt.test.ts index 042e0220..33cfce3f 100644 --- a/src/middleware/jwt.test.ts +++ b/src/middleware/jwt.test.ts @@ -1,4 +1,5 @@ import { mockDeep } from 'jest-mock-extended' +import { DEX_NO_GROUPS_SENTINEL } from 'src/clients/dexConstants' import { JWT } from 'src/otomi-models' import OtomiStack from 'src/otomi-stack' import { loadSpec } from '../app' @@ -31,6 +32,7 @@ const platformAdminJWT: JWT = { const teamAdminJWT: JWT = { ...platformAdminJWT, groups: teamAdminGroups } const teamMemberJWT: JWT = { ...platformAdminJWT, groups: teamMemberGroups } const multiTeamJWT: JWT = { ...platformAdminJWT, groups: multiTeamGroups } +const noGroupsJWT: JWT = { ...platformAdminJWT, groups: [DEX_NO_GROUPS_SENTINEL] } describe('JWT claims mapping', () => { let otomiStack: OtomiStack @@ -65,6 +67,14 @@ describe('JWT claims mapping', () => { expect(user.isTeamAdmin).toBeFalsy() }) + test('Dex no-groups sentinel grants no role and no team membership', () => { + const user = getUser(noGroupsJWT, otomiStack) + expect(user.roles).toEqual([]) + expect(user.teams).toEqual([]) + expect(user.isPlatformAdmin).toBeFalsy() + expect(user.isTeamAdmin).toBeFalsy() + }) + test('Multiple team groups should result in the same amount of teams existing', async () => { await Promise.all( multiTeamUser.map(async (teamId) => diff --git a/src/middleware/jwt.ts b/src/middleware/jwt.ts index 2968d162..5c70ea8b 100644 --- a/src/middleware/jwt.ts +++ b/src/middleware/jwt.ts @@ -1,6 +1,7 @@ /* eslint-disable no-param-reassign */ import Debug from 'debug' import { RequestHandler } from 'express' +import { DEX_NO_GROUPS_SENTINEL } from 'src/clients/dexConstants' import { verifyJwt } from 'src/jwt-verification' import { getMockEmail, getMockGroups, getMockName } from 'src/mocks' import { JWT, OpenApiRequestExt, SessionUser } from 'src/otomi-models' @@ -24,6 +25,8 @@ export function getUser(user: JWT, otomi: OtomiStack): SessionUser { // keycloak does not (yet) give roles, so // for now we map correct group names to roles user?.groups?.forEach((group) => { + // Dex-only placeholder for "no groups" (see DEX_NO_GROUPS_SENTINEL) - carries no role or team. + if (group === DEX_NO_GROUPS_SENTINEL) return if (['platform-admin', 'all-teams-admin'].includes(group)) { if (!sessionUser.roles.includes('platformAdmin')) { sessionUser.isPlatformAdmin = true @@ -37,7 +40,7 @@ export function getUser(user: JWT, otomi: OtomiStack): SessionUser { } else if (!sessionUser.roles.includes('teamMember')) sessionUser.roles.push('teamMember') // if in team-(not admin), remove 'team-' prefix const teamId = group.substring(5) - if (group.substring(0, 5) === 'team-' && !sessionUser.teams.includes(teamId)) { + if (group.substring(0, 5) === 'team-' && group !== 'team-admin' && !sessionUser.teams.includes(teamId)) { // we might be assigned team-* without that team yet existing in the values, so ignore those if (otomi.isLoaded) { const exists = otomi.getTeamIds().includes(teamId) diff --git a/src/openapi/api.yaml b/src/openapi/api.yaml index 187a5256..08c7877b 100644 --- a/src/openapi/api.yaml +++ b/src/openapi/api.yaml @@ -1197,6 +1197,9 @@ paths: $ref: '#/components/schemas/User/properties/id' teams: $ref: '#/components/schemas/User/properties/teams' + required: + - id + - teams description: User object that contains updated values required: true responses: diff --git a/src/openapi/settings.yaml b/src/openapi/settings.yaml index 99ccff0a..1fe61454 100644 --- a/src/openapi/settings.yaml +++ b/src/openapi/settings.yaml @@ -213,6 +213,10 @@ Settings: type: string description: Set OIDC claim to be passed by Keycloak as a unique user identifier. It is advised to not change the default. default: sub + groupsClaimMapper: + type: string + description: Claim name the identity provider uses for group membership. Some providers require a namespaced name instead of "groups". + default: groups type: object x-externalDocsPath: oidc-settings x-acl: @@ -278,6 +282,21 @@ Settings: platformAdmin: [read-any, update-any] teamAdmin: [] teamMember: [] + oidc: + type: object + additionalProperties: false + properties: + authenticationLayer: + type: string + enum: + - dex + - keycloak + default: keycloak + description: Which app every consumer authenticates against. + x-acl: + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] aiEnabled: type: boolean default: false diff --git a/src/openapi/user.yaml b/src/openapi/user.yaml index c1e6a740..a38fa1ec 100644 --- a/src/openapi/user.yaml +++ b/src/openapi/user.yaml @@ -115,9 +115,9 @@ User: uniqueItems: true initialPassword: type: string - description: The initial password of the user + minLength: 8 + maxLength: 32 + description: The initial password of the user. With Dex as issuer, an admin may set this on create; otherwise one is generated. required: - email - - firstName - - lastName type: object diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index 408c5d88..c4d87a26 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -1,3 +1,4 @@ +import bcrypt from 'bcryptjs' import { mockDeep } from 'jest-mock-extended' import { AplCodeRepoResponse, @@ -61,6 +62,24 @@ jest.mock('./k8s-operations', () => { } }) +const mockCreateDexPassword = jest.fn().mockResolvedValue(undefined) +const mockUpdateDexPassword = jest.fn().mockResolvedValue(undefined) +const mockDeleteDexPassword = jest.fn().mockResolvedValue(undefined) +const mockListDexPasswords = jest.fn().mockResolvedValue([]) +const mockListUserIdentitiesByUserId = jest.fn().mockResolvedValue([]) +const mockDeleteDexUserIdentity = jest.fn().mockResolvedValue(undefined) +jest.mock('./clients/dexClient', () => ({ + __esModule: true, + createDexPassword: (...args: any[]) => mockCreateDexPassword(...args), + updateDexPassword: (...args: any[]) => mockUpdateDexPassword(...args), + deleteDexPassword: (...args: any[]) => mockDeleteDexPassword(...args), + listDexPasswords: (...args: any[]) => mockListDexPasswords(...args), + listUserIdentitiesByUserId: (...args: any[]) => mockListUserIdentitiesByUserId(...args), + deleteDexUserIdentity: (...args: any[]) => mockDeleteDexUserIdentity(...args), + DEX_NO_GROUPS_SENTINEL: '__no_groups__', + DexProvisionError: class DexProvisionError extends Error {}, +})) + jest.mock('./utils/sealedSecretUtils', () => { const originalModule = jest.requireActual('./utils/sealedSecretUtils') return { @@ -591,6 +610,14 @@ describe('Users tests', () => { expect(mockGit.writeTextFile).toHaveBeenCalled() expect(otomiStack.doDeployment).toHaveBeenCalled() }) + + it('should allow creating a user without a firstName or lastName', async () => { + const result = await otomiStack.createUser({ email: 'no-name-user@dev.linode-apl.net' } as User) + + expect(result.email).toEqual('no-name-user@dev.linode-apl.net') + expect(mockGit.writeTextFile).toHaveBeenCalled() + expect(otomiStack.doDeployment).toHaveBeenCalled() + }) }) describe('Reserved Username Validation', () => { @@ -809,6 +836,12 @@ describe('Users tests', () => { await expect(otomiStack.editTeamUsers(data, sessionUser)).rejects.toThrow() }) + it('should reject a request that omits teams instead of silently dropping all memberships', async () => { + const platformAdmin = { ...sessionUser, isPlatformAdmin: true, isTeamAdmin: false } + const data = [{ id: teamMember1.id }] as Pick[] + await expect(otomiStack.editTeamUsers(data, platformAdmin)).rejects.toMatchObject({ code: 400 }) + }) + it('should not allow regular user to update teams', async () => { const regularUser = { name: 'Regular User', @@ -828,6 +861,440 @@ describe('Users tests', () => { }) }) }) + + describe('Dex provisioning', () => { + const originalAuthProvider = process.env.AUTH_PROVIDER + + afterEach(() => { + process.env.AUTH_PROVIDER = originalAuthProvider + jest.clearAllMocks() + }) + + // env.AUTH_PROVIDER is captured once, at module-load time, by otomi-stack.ts's own cleanEnv() call. + // Toggling process.env.AUTH_PROVIDER between tests only takes effect if we reset the module registry + // and re-import otomi-stack (and its FileStore dependency) fresh, mirroring the pattern already used + // for env-dependent module behavior in src/middleware/session.test.ts. + async function getTestStack(authProvider: 'keycloak' | 'dex'): Promise { + process.env.AUTH_PROVIDER = authProvider + + // jest.isolateModules(Async) loads a private, sandboxed copy of the module graph for the + // duration of the callback and then restores the file's shared module registry — unlike + // jest.resetModules(), it does not leave later describe blocks in this file (which `require()` + // 'src/middleware' etc. ad hoc) pointing at a different module instance than the one + // statically imported at the top of this file. + let stack!: OtomiStack + await jest.isolateModulesAsync(async () => { + const FreshOtomiStack = require('src/otomi-stack').default + + const FreshFileStore = require('./fileStore/file-store').FileStore + + stack = new FreshOtomiStack() as OtomiStack + await stack.init() + stack.fileStore = new FreshFileStore() + }) + stack.git = mockDeep() + + jest.spyOn(stack, 'doDeleteDeployment').mockResolvedValue() + jest.spyOn(stack, 'doDeployment').mockResolvedValue() + jest.spyOn(stack, 'doDeployments').mockResolvedValue() + jest.spyOn(stack, 'getSettings').mockResolvedValue({ + cluster: { name: 'default-cluster', domainSuffix, provider: 'linode' }, + }) + jest.spyOn(stack, 'getApp').mockReturnValue({ id: 'keycloak' }) + + return stack + } + + // A Dex Password record, as returned by ListPasswords — this is the only source of truth + // for a dex-provisioned user's email/groups once AUTH_PROVIDER=dex (no SealedSecret exists). + function dexPassword(overrides: Partial<{ userId: string; email: string; groups: string[] }> = {}) { + return { + email: 'existing@example.com', + hash: Buffer.from('$2a$10$existinghash'), + username: 'existing', + userId: 'uuid-1', + groups: ['team-blue'], + ...overrides, + } + } + + it('createUser does not call Dex when AUTH_PROVIDER is keycloak (default)', async () => { + const otomi = await getTestStack('keycloak') + await otomi.createUser({ + email: 'newuser@example.com', + firstName: 'New', + lastName: 'User', + isPlatformAdmin: false, + isTeamAdmin: false, + teams: [], + } as User) + + expect(mockCreateDexPassword).not.toHaveBeenCalled() + }) + + it('createUser calls Dex CreatePassword with derived groups and a bcrypt hash, and writes nothing to Git, when AUTH_PROVIDER is dex', async () => { + const otomi = await getTestStack('dex') + createTestTeam(otomi, 'blue') + + await otomi.createUser({ + email: 'newuser@example.com', + firstName: 'New', + lastName: 'User', + isPlatformAdmin: true, + isTeamAdmin: false, + teams: ['blue'], + } as User) + + expect(mockCreateDexPassword).toHaveBeenCalledTimes(1) + const call = mockCreateDexPassword.mock.calls[0][0] + expect(call.email).toEqual('newuser@example.com') + expect(call.username).toEqual('newuser') + expect(call.groups).toEqual(['platform-admin', 'team-blue']) + expect(typeof call.passwordHash).toEqual('string') + expect(call.passwordHash.length).toBeGreaterThan(0) + + expect(otomi.git.writeTextFile).not.toHaveBeenCalled() + expect(otomi.doDeployment).not.toHaveBeenCalled() + }) + + it('createUser uses an admin-supplied initialPassword when AUTH_PROVIDER is dex', async () => { + const otomi = await getTestStack('dex') + + const user = await otomi.createUser({ + email: 'chosen@example.com', + isPlatformAdmin: false, + isTeamAdmin: false, + teams: [], + initialPassword: 'a-chosen-password', + } as User) + + expect(user.initialPassword).toEqual('a-chosen-password') + const call = mockCreateDexPassword.mock.calls[0][0] + expect(await bcrypt.compare('a-chosen-password', call.passwordHash)).toBe(true) + }) + + it('createUser rejects an admin-supplied initialPassword shorter than the minimum, when AUTH_PROVIDER is dex', async () => { + const otomi = await getTestStack('dex') + + await expect( + otomi.createUser({ + email: 'tooshort@example.com', + isPlatformAdmin: false, + isTeamAdmin: false, + teams: [], + initialPassword: 'short', + } as User), + ).rejects.toMatchObject({ code: 400 }) + + expect(mockCreateDexPassword).not.toHaveBeenCalled() + }) + + it('createUser rejects an admin-supplied initialPassword over 72 bytes, when AUTH_PROVIDER is dex', async () => { + const otomi = await getTestStack('dex') + + await expect( + otomi.createUser({ + email: 'toolong@example.com', + isPlatformAdmin: false, + isTeamAdmin: false, + teams: [], + initialPassword: 'a'.repeat(73), + } as User), + ).rejects.toMatchObject({ code: 400 }) + + expect(mockCreateDexPassword).not.toHaveBeenCalled() + }) + + it('createUser ignores a caller-supplied initialPassword and generates one when AUTH_PROVIDER is keycloak', async () => { + const otomi = await getTestStack('keycloak') + + const user = await otomi.createUser({ + email: 'ignored@example.com', + firstName: 'I', + lastName: 'G', + isPlatformAdmin: false, + isTeamAdmin: false, + teams: [], + initialPassword: 'attempted-password', + } as User) + + expect(user.initialPassword).not.toEqual('attempted-password') + expect(user.initialPassword!.length).toBeGreaterThan(0) + }) + + it('createUser aborts and writes nothing to Git when Dex provisioning fails', async () => { + mockCreateDexPassword.mockRejectedValueOnce(new Error('dex unavailable')) + const otomi = await getTestStack('dex') + + await expect( + otomi.createUser({ + email: 'newuser@example.com', + firstName: 'New', + lastName: 'User', + isPlatformAdmin: false, + isTeamAdmin: false, + teams: [], + } as User), + ).rejects.toThrow() + + expect(otomi.git.writeTextFile).not.toHaveBeenCalled() + }) + + it('createUser dedupes against Dex, not Git, when AUTH_PROVIDER is dex', async () => { + mockListDexPasswords.mockResolvedValue([dexPassword({ email: 'dupe@example.com' })]) + const otomi = await getTestStack('dex') + + await expect( + otomi.createUser({ email: 'dupe@example.com', isPlatformAdmin: false, isTeamAdmin: false, teams: [] } as User), + ).rejects.toMatchObject({ publicMessage: 'User email already exists' }) + + expect(mockCreateDexPassword).not.toHaveBeenCalled() + }) + + it('getUser reads the record back from Dex when AUTH_PROVIDER is dex', async () => { + mockListDexPasswords.mockResolvedValue([ + dexPassword({ userId: 'uuid-1', groups: ['platform-admin', 'team-blue'] }), + ]) + const otomi = await getTestStack('dex') + const sessionUserArg = { isPlatformAdmin: true } as unknown as SessionUser + + const user = await otomi.getUser('uuid-1', sessionUserArg) + + expect(user).toMatchObject({ + id: 'uuid-1', + email: 'existing@example.com', + isPlatformAdmin: true, + isTeamAdmin: false, + teams: ['blue'], + }) + }) + + it('getAllUsers reads the full list back from Dex when AUTH_PROVIDER is dex', async () => { + mockListDexPasswords.mockResolvedValue([ + dexPassword({ userId: 'uuid-1', email: 'a@example.com', groups: ['team-blue'] }), + dexPassword({ userId: 'uuid-2', email: 'b@example.com', groups: ['platform-admin'] }), + ]) + const otomi = await getTestStack('dex') + const sessionUserArg = { isPlatformAdmin: true } as unknown as SessionUser + + const users = await otomi.getAllUsers(sessionUserArg) + + expect(users).toHaveLength(2) + expect(users.map((u) => u.email)).toEqual(['a@example.com', 'b@example.com']) + }) + + it('editUser looks the record up in Dex, sends newGroups, and writes nothing to Git when AUTH_PROVIDER is dex', async () => { + mockListDexPasswords.mockResolvedValue([ + dexPassword({ userId: 'uuid-2', email: 'legacy@example.com', groups: [] }), + ]) + const otomi = await getTestStack('dex') + const sessionUserArg = { isPlatformAdmin: true } as unknown as SessionUser + + const user = await otomi.editUser('uuid-2', { isTeamAdmin: true } as User, sessionUserArg) + + expect(mockUpdateDexPassword).toHaveBeenCalledTimes(1) + const call = mockUpdateDexPassword.mock.calls[0][0] + expect(call.email).toEqual('legacy@example.com') + expect(call.newHash).toBeUndefined() + expect(call.newGroups).toEqual(['team-admin']) + expect(user.isTeamAdmin).toBe(true) + + expect(otomi.git.writeTextFile).not.toHaveBeenCalled() + expect(otomi.doDeployment).not.toHaveBeenCalled() + }) + + it('editUser recomputes the hash and sends it as newHash when a new initialPassword is supplied, in dex mode', async () => { + mockListDexPasswords.mockResolvedValue([ + dexPassword({ userId: 'uuid-5', email: 'changing@example.com', groups: [] }), + ]) + const otomi = await getTestStack('dex') + const sessionUserArg = { isPlatformAdmin: true } as unknown as SessionUser + + await otomi.editUser('uuid-5', { initialPassword: 'brand-new-plaintext' } as User, sessionUserArg) + + expect(mockUpdateDexPassword).toHaveBeenCalledTimes(1) + const call = mockUpdateDexPassword.mock.calls[0][0] + expect(call.email).toEqual('changing@example.com') + expect(typeof call.newHash).toEqual('string') + expect(call.newHash.length).toBeGreaterThan(0) + }) + + it("editUser preserves an existing user's privileges on a password-only reset, in dex mode", async () => { + mockListDexPasswords.mockResolvedValue([ + dexPassword({ + userId: 'uuid-priv', + email: 'privileged@example.com', + groups: ['platform-admin', 'team-admin'], + }), + ]) + const otomi = await getTestStack('dex') + const sessionUserArg = { isPlatformAdmin: true } as unknown as SessionUser + + await otomi.editUser('uuid-priv', { initialPassword: 'brand-new-plaintext' } as User, sessionUserArg) + + expect(mockUpdateDexPassword).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'privileged@example.com', + newGroups: expect.arrayContaining(['platform-admin', 'team-admin']), + }), + ) + const call = mockUpdateDexPassword.mock.calls[0][0] + expect(call.newGroups).toHaveLength(2) + }) + + it('editUser ignores an attempted email change in dex mode, since Dex has no way to apply it', async () => { + mockListDexPasswords.mockResolvedValue([ + dexPassword({ userId: 'uuid-6', email: 'original@example.com', groups: [] }), + ]) + const otomi = await getTestStack('dex') + const sessionUserArg = { isPlatformAdmin: true } as unknown as SessionUser + + const user = await otomi.editUser('uuid-6', { email: 'changed@example.com' } as User, sessionUserArg) + + expect(user.email).toEqual('original@example.com') + expect(mockUpdateDexPassword).toHaveBeenCalledWith(expect.objectContaining({ email: 'original@example.com' })) + }) + + it('editUser rejects a too-short initialPassword in dex mode without calling Dex', async () => { + mockListDexPasswords.mockResolvedValue([ + dexPassword({ userId: 'uuid-7', email: 'shortpw@example.com', groups: [] }), + ]) + const otomi = await getTestStack('dex') + const sessionUserArg = { isPlatformAdmin: true } as unknown as SessionUser + + await expect( + otomi.editUser('uuid-7', { initialPassword: 'short' } as User, sessionUserArg), + ).rejects.toMatchObject({ code: 400 }) + + expect(mockUpdateDexPassword).not.toHaveBeenCalled() + }) + + it('editTeamUsers looks users up in Dex, calls Dex UpdatePassword with the new groups, and writes nothing to Git', async () => { + mockListDexPasswords.mockResolvedValue([dexPassword({ userId: 'uuid-1', groups: ['team-blue'] })]) + const otomi = await getTestStack('dex') + createTestTeam(otomi, 'blue') + createTestTeam(otomi, 'red') + const sessionUserArg = { isPlatformAdmin: true, isTeamAdmin: false, teams: [] } as unknown as SessionUser + + const result = await otomi.editTeamUsers([{ id: 'uuid-1', teams: ['blue', 'red'] }], sessionUserArg) + + expect(mockUpdateDexPassword).toHaveBeenCalledWith( + expect.objectContaining({ email: 'existing@example.com', newGroups: ['team-blue', 'team-red'] }), + ) + expect(result).toEqual([{ id: 'uuid-1', teams: ['blue', 'red'] }]) + expect(otomi.git.writeTextFile).not.toHaveBeenCalled() + expect(otomi.doDeployments).not.toHaveBeenCalled() + }) + + it('editTeamUsers rejects a request that omits teams instead of wiping all groups in Dex', async () => { + mockListDexPasswords.mockResolvedValue([dexPassword({ userId: 'uuid-1', groups: ['team-blue'] })]) + const otomi = await getTestStack('dex') + const sessionUserArg = { isPlatformAdmin: true, isTeamAdmin: false, teams: [] } as unknown as SessionUser + + await expect( + otomi.editTeamUsers([{ id: 'uuid-1' } as Pick], sessionUserArg), + ).rejects.toMatchObject({ code: 400 }) + + expect(mockUpdateDexPassword).not.toHaveBeenCalled() + }) + + it('editTeamUsers rejects a request naming a team that does not exist instead of persisting it as a Dex group', async () => { + mockListDexPasswords.mockResolvedValue([dexPassword({ userId: 'uuid-1', groups: ['team-blue'] })]) + const otomi = await getTestStack('dex') + createTestTeam(otomi, 'blue') + const sessionUserArg = { isPlatformAdmin: true, isTeamAdmin: false, teams: [] } as unknown as SessionUser + + await expect( + otomi.editTeamUsers([{ id: 'uuid-1', teams: ['blue', 'does-not-exist'] }], sessionUserArg), + ).rejects.toMatchObject({ code: 404 }) + + expect(mockUpdateDexPassword).not.toHaveBeenCalled() + }) + + it('editTeamUsers rejects and writes nothing to Git when a Dex call fails partway through a batch', async () => { + mockListDexPasswords.mockResolvedValue([ + dexPassword({ userId: 'user-a', email: 'user-a@example.com', groups: ['team-blue'] }), + dexPassword({ userId: 'user-b', email: 'user-b@example.com', groups: ['team-blue'] }), + ]) + mockUpdateDexPassword.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('dex unavailable')) + const otomi = await getTestStack('dex') + createTestTeam(otomi, 'blue') + createTestTeam(otomi, 'red') + const sessionUserArg = { isPlatformAdmin: true, isTeamAdmin: false, teams: [] } as unknown as SessionUser + + await expect( + otomi.editTeamUsers( + [ + { id: 'user-a', teams: ['blue', 'red'] }, + { id: 'user-b', teams: ['blue', 'red'] }, + ], + sessionUserArg, + ), + ).rejects.toThrow() + + expect(mockUpdateDexPassword).toHaveBeenCalledTimes(2) + expect(otomi.git.writeTextFile).not.toHaveBeenCalled() + expect(otomi.doDeployments).not.toHaveBeenCalled() + }) + + it('deleteUser looks the record up in Dex, calls Dex DeletePassword, and removes nothing from Git', async () => { + mockListDexPasswords.mockResolvedValue([dexPassword({ userId: 'uuid-3', email: 'todelete@example.com' })]) + const otomi = await getTestStack('dex') + + await otomi.deleteUser('uuid-3') + + expect(mockDeleteDexPassword).toHaveBeenCalledWith('todelete@example.com') + expect(otomi.git.removeFile).not.toHaveBeenCalled() + }) + + it('deleteUser purges the Dex identity (session/token cascade) before deleting the password', async () => { + mockListDexPasswords.mockResolvedValue([dexPassword({ userId: 'uuid-5', email: 'loggedin@example.com' })]) + mockListUserIdentitiesByUserId.mockResolvedValueOnce([{ userId: 'uuid-5', connectorId: 'local' }]) + const otomi = await getTestStack('dex') + + await otomi.deleteUser('uuid-5') + + expect(mockListUserIdentitiesByUserId).toHaveBeenCalledWith('uuid-5') + expect(mockDeleteDexUserIdentity).toHaveBeenCalledWith('uuid-5', 'local') + expect(mockDeleteDexPassword).toHaveBeenCalledWith('loggedin@example.com') + }) + + it('deleteUser resumes purging remaining identities when a prior partial purge already deleted the password', async () => { + // Simulates a retry after DeleteUserIdentity's cascade already removed the password + // record on a previous attempt, before a later identity's deletion failed. + mockListDexPasswords.mockResolvedValue([]) + mockListUserIdentitiesByUserId.mockResolvedValueOnce([ + { userId: 'uuid-6', connectorId: 'google', email: 'resumed@example.com' }, + ]) + const otomi = await getTestStack('dex') + + await otomi.deleteUser('uuid-6') + + expect(mockDeleteDexUserIdentity).toHaveBeenCalledWith('uuid-6', 'google') + expect(mockDeleteDexPassword).toHaveBeenCalledWith('resumed@example.com') + }) + + it('deleteUser is a no-op when neither a password nor an identity remains (already fully purged)', async () => { + mockListDexPasswords.mockResolvedValue([]) + mockListUserIdentitiesByUserId.mockResolvedValueOnce([]) + const otomi = await getTestStack('dex') + + await expect(otomi.deleteUser('uuid-7')).resolves.toBeUndefined() + + expect(mockDeleteDexUserIdentity).not.toHaveBeenCalled() + expect(mockDeleteDexPassword).not.toHaveBeenCalled() + }) + + it('deleteUser aborts and calls nothing else when Dex provisioning fails', async () => { + mockListDexPasswords.mockResolvedValue([dexPassword({ userId: 'uuid-4', email: 'todelete-fail@example.com' })]) + mockDeleteDexPassword.mockRejectedValueOnce(new Error('dex unavailable')) + const otomi = await getTestStack('dex') + + await expect(otomi.deleteUser('uuid-4')).rejects.toThrow() + + expect(otomi.git.removeFile).not.toHaveBeenCalled() + }) + }) }) describe('getVersions', () => { diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 481f6180..7b5de91e 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -106,6 +106,7 @@ import { assertServiceNameNotReserved } from 'src/utils/serviceUtils' import { deepQuote } from 'src/utils/yamlUtils' import { API_NAMESPACE, + AUTH_PROVIDER, CATALOG_CACHE_PATH, cleanEnv, CUSTOM_ROOT_CA, @@ -147,9 +148,19 @@ import { mergeCanaryServices, setApiStatusInConfigMap, toK8sService, + UserSecretData, watchPodUntilRunning, } from './k8s-operations' import CloudTty from './tty' +import { + createDexPassword, + deleteDexPassword, + deleteDexUserIdentity, + listDexPasswords, + listUserIdentitiesByUserId, + Password, + updateDexPassword, +} from './clients/dexClient' import { extractRepositoryRefs, getAuthenticatedGitClient, @@ -158,6 +169,7 @@ import { } from './utils/codeRepoUtils' import { isKnativeSupported } from './utils/k8sUtils' import { getV1ObjectFromApl } from './utils/manifests' +import { hashPassword } from './utils/passwordUtils' import { createUserSealedSecret, encryptAndMergeSecrets, @@ -171,6 +183,8 @@ import { sealedSecretManifest, } from './utils/sealedSecretUtils' import { + deriveDexGroups, + dexPasswordToUser, getKeycloakUsers, getUserSecretData, isValidUsername, @@ -188,6 +202,7 @@ const debug = Debug('otomi:otomi-stack') const env = cleanEnv({ API_NAMESPACE, + AUTH_PROVIDER, CATALOG_CACHE_PATH, CUSTOM_ROOT_CA, DEFAULT_PLATFORM_ADMIN_EMAIL, @@ -215,6 +230,8 @@ const env = cleanEnv({ export const rootPath = '/tmp/otomi/values' const clusterSettingsFilePath = 'env/settings/cluster.yaml' +const MIN_USER_PASSWORD_LENGTH = 8 +const MAX_USER_PASSWORD_BYTES = 72 function getTeamSealedSecretsValuesFilePath(teamId: string, sealedSecretsName: string): string { return `env/teams/${teamId}/sealedsecrets/${sealedSecretsName}.yaml` @@ -1240,17 +1257,25 @@ export default class OtomiStack { } async getAllUsers(sessionUser: SessionUser): Promise> { + const users = await this.listAllUsers() + return users.map((user) => this.trimUserForSession(user, sessionUser)) + } + + private async listAllUsers(): Promise { + if (env.AUTH_PROVIDER === 'dex') { + return (await listDexPasswords()).map(dexPasswordToUser) + } const usersData = await listUserSecretData(this.getAplNamespaceSealedSecrets.bind(this)) - const users: User[] = usersData.map((u) => userSecretDataToUser(u)) + return usersData.map((u) => userSecretDataToUser(u)) + } + private trimUserForSession(user: User, sessionUser: SessionUser): User { if (sessionUser.isPlatformAdmin) { - return users - } else if (sessionUser.isTeamAdmin) { - const usersWithBasicInfo = users.map((user) => { - const { id, email, isPlatformAdmin, isTeamAdmin, teams } = user - return { id, email, isPlatformAdmin, isTeamAdmin, teams } as User - }) - return usersWithBasicInfo + return user + } + if (sessionUser.isTeamAdmin) { + const { id, email, isPlatformAdmin, isTeamAdmin, teams } = user + return { id, email, isPlatformAdmin, isTeamAdmin, teams } as User } throw new ForbiddenError() } @@ -1261,7 +1286,42 @@ export default class OtomiStack { throw new HttpError(400, error as string) } - const initialPassword = generatePassword({ + const initialPassword = this.resolveInitialPassword(data.initialPassword) + const user: User = { ...data, id: uuidv4(), initialPassword } + + this.validateUserTeamsExist(user) + await this.assertUserEmailAvailable(user.email) + + if (env.AUTH_PROVIDER === 'dex') { + await this.provisionDexUser(user, initialPassword) + return user + } + + const aplRecord = await this.saveUser(user) + await this.doDeployment(aplRecord) + return user + } + + private resolveInitialPassword(suppliedPassword?: string): string { + if (env.AUTH_PROVIDER !== 'dex' || !suppliedPassword) { + return this.generateInitialPassword() + } + this.assertPasswordLength(suppliedPassword) + return suppliedPassword + } + + private assertPasswordLength(password: string): void { + if (password.length < MIN_USER_PASSWORD_LENGTH) { + throw new HttpError(400, `Password must be at least ${MIN_USER_PASSWORD_LENGTH} characters.`) + } + // bcrypt only reads the first 72 bytes. + if (Buffer.byteLength(password, 'utf-8') > MAX_USER_PASSWORD_BYTES) { + throw new HttpError(400, `Password must be at most ${MAX_USER_PASSWORD_BYTES} bytes.`) + } + } + + private generateInitialPassword(): string { + return generatePassword({ length: 16, numbers: true, symbols: '!@#$%&*', @@ -1269,55 +1329,76 @@ export default class OtomiStack { uppercase: true, strict: true, }) + } - const userId = uuidv4() - const user: User = { ...data, id: userId, initialPassword } + private async assertUserEmailAvailable(email: string): Promise { + const existingEmails = await this.listExistingUserEmails() + if (existingEmails.includes(email)) { + throw new AlreadyExists('User email already exists') + } + } - this.validateUserTeamsExist(user) + private async listExistingUserEmails(): Promise { + if (env.AUTH_PROVIDER === 'dex') { + return (await listDexPasswords()).map((p) => p.email) + } const existingUsers = await listUserSecretData(this.getAplNamespaceSealedSecrets.bind(this)) - const existingUsersEmail = existingUsers.map((u) => u.email) - + const emails = existingUsers.map((u) => u.email) if (!env.isDev) { - // In production, also check Keycloak for existing users - const { cluster } = await this.getSettings(['cluster']) - const keycloak = this.getApp('keycloak') - const keycloakBaseUrl = `https://keycloak.${cluster?.domainSuffix}` - const realm = 'otomi' - const username = keycloak?.values?.adminUsername as string - const platformSecrets = await getSecretValues(PLATFORM_SECRETS_NAME, APL_SECRETS_NAMESPACE) - const adminPassword = platformSecrets?.adminPassword - if (!adminPassword) { - throw new HttpError(500, 'Admin password not found in platform secrets') - } - const keycloakEmails = await getKeycloakUsers(keycloakBaseUrl, realm, username, adminPassword) - existingUsersEmail.push(...keycloakEmails.filter((e) => !existingUsersEmail.includes(e))) - } - - if (existingUsersEmail.some((existingUser) => existingUser === user.email)) { - throw new AlreadyExists('User email already exists') + emails.push(...(await this.fetchKeycloakEmailsNotIn(emails))) } + return emails + } - const aplRecord = await this.saveUser(user) - await this.doDeployment(aplRecord) - return user + private async fetchKeycloakEmailsNotIn(existingEmails: string[]): Promise { + const { cluster } = await this.getSettings(['cluster']) + const keycloak = this.getApp('keycloak') + const keycloakBaseUrl = `https://keycloak.${cluster?.domainSuffix}` + const realm = 'otomi' + const username = keycloak?.values?.adminUsername as string + const platformSecrets = await getSecretValues(PLATFORM_SECRETS_NAME, APL_SECRETS_NAMESPACE) + const adminPassword = platformSecrets?.adminPassword + if (!adminPassword) { + throw new HttpError(500, 'Admin password not found in platform secrets') + } + const keycloakEmails = await getKeycloakUsers(keycloakBaseUrl, realm, username, adminPassword) + return keycloakEmails.filter((email) => !existingEmails.includes(email)) + } + + private async provisionDexUser(user: User, plaintextPassword: string): Promise { + const passwordHash = await hashPassword(plaintextPassword) + await createDexPassword({ + id: user.id as string, + email: user.email, + passwordHash, + username: user.email.split('@')[0], + groups: deriveDexGroups(user), + }) } async getUser(id: string, sessionUser: SessionUser): Promise { + const user = + env.AUTH_PROVIDER === 'dex' + ? (await this.lookupDexUser(id)).user + : userSecretDataToUser(await this.requireUserSecretData(id)) + return this.trimUserForSession(user, sessionUser) + } + + private async requireUserSecretData(id: string): Promise { const userData = await getUserSecretData(id, this.fileStore) if (!userData) { throw new NotExistError(`User ${id} not found`) } - const user = userSecretDataToUser(userData) + return userData + } - if (sessionUser.isPlatformAdmin) { - return user - } - if (sessionUser.isTeamAdmin) { - const { email, isPlatformAdmin, isTeamAdmin, teams } = user - return { id, email, isPlatformAdmin, isTeamAdmin, teams } as User + private async lookupDexUser(id: string): Promise<{ match: Password; user: User }> { + const match = (await listDexPasswords()).find((p) => p.userId === id) + if (!match) { + throw new NotExistError(`User ${id} not found`) } - throw new ForbiddenError() + return { match, user: dexPasswordToUser(match) } } async editUser(id: string, data: User, sessionUser: SessionUser): Promise { @@ -1325,25 +1406,45 @@ export default class OtomiStack { throw new ForbiddenError('Only platform admins can modify user details.') } - const existingData = await getUserSecretData(id, this.fileStore) - if (!existingData) { - throw new NotExistError(`User ${id} not found`) - } - - const existingUser = userSecretDataToUser(existingData) + return env.AUTH_PROVIDER === 'dex' ? this.editDexUser(id, data) : this.editGitUser(id, data) + } + private async editDexUser(id: string, data: User): Promise { + const { match, user: existingUser } = await this.lookupDexUser(id) + // email is immutable in Dex; groups are merged individually so an omitted field is kept, not wiped. const user: User = { ...existingUser, ...data, id, - initialPassword: existingUser.initialPassword, + email: existingUser.email, + isPlatformAdmin: data.isPlatformAdmin ?? existingUser.isPlatformAdmin, + isTeamAdmin: data.isTeamAdmin ?? existingUser.isTeamAdmin, + teams: data.teams ?? existingUser.teams, + } + this.validateUserTeamsExist(user) + + // Dex already holds a real hash from creation — there's nothing to back-fill, and a hash + // is only ever recomputed here when the caller actually supplied a new plaintext password. + if (data.initialPassword) { + this.assertPasswordLength(data.initialPassword) } + const newHash = data.initialPassword ? await hashPassword(data.initialPassword) : undefined + await updateDexPassword({ + email: match.email, + newHash, + newGroups: deriveDexGroups(user), + }) + return user + } + private async editGitUser(id: string, data: User): Promise { + const existingData = await this.requireUserSecretData(id) + const existingUser = userSecretDataToUser(existingData) + const user: User = { ...existingUser, ...data, id, initialPassword: existingUser.initialPassword } this.validateUserTeamsExist(user) const aplRecord = await this.saveUser(user) await this.doDeployment(aplRecord) - return user } @@ -1359,15 +1460,35 @@ export default class OtomiStack { } async deleteUser(id: string): Promise { - const existingData = await getUserSecretData(id, this.fileStore) - if (!existingData) { - throw new NotExistError(`User ${id} not found`) + if (env.AUTH_PROVIDER === 'dex') { + await this.deleteDexUser(id) + return + } + await this.deleteGitUser(id) + } + + private async deleteDexUser(id: string): Promise { + // Deleting an identity cascades to its password record, so on retry the password may + // already be gone. Look up both instead of gating on the password alone. + const [passwords, identities] = await Promise.all([listDexPasswords(), listUserIdentitiesByUserId(id)]) + const email = passwords.find((p) => p.userId === id)?.email ?? identities[0]?.email + if (!email) return // already fully purged + + if (email === env.DEFAULT_PLATFORM_ADMIN_EMAIL) { + throw new ForbiddenError('Cannot delete the default platform admin user') } + for (const identity of identities) { + await deleteDexUserIdentity(identity.userId, identity.connectorId) + } + await deleteDexPassword(email) + } + + private async deleteGitUser(id: string): Promise { + const existingData = await this.requireUserSecretData(id) if (existingData.email === env.DEFAULT_PLATFORM_ADMIN_EMAIL) { throw new ForbiddenError('Cannot delete the default platform admin user') } - // Remove SealedSecret manifest from git const sealedSecretPath = getNamespaceSealedSecretsValuesFilePath(APL_USERS_NAMESPACE, id) await this.git.removeFile(sealedSecretPath) @@ -1402,6 +1523,14 @@ export default class OtomiStack { return isValid } + private assertCanUpdateUserTeams(sessionUser: SessionUser, existingUser: User, newTeams: string[]): void { + if (!sessionUser.isPlatformAdmin && !this.canTeamAdminUpdateUserTeams(sessionUser, existingUser, newTeams)) { + throw new ForbiddenError( + 'Team admins are permitted to add or remove users only within the teams they manage. However, they cannot remove themselves or other team admins from those teams.', + ) + } + } + async editTeamUsers( data: Pick[], sessionUser: SessionUser, @@ -1410,27 +1539,65 @@ export default class OtomiStack { throw new ForbiddenError("Only platform admins or team admins can modify a user's team memberships.") } + return env.AUTH_PROVIDER === 'dex' + ? this.editDexTeamUsers(data, sessionUser) + : this.editGitTeamUsers(data, sessionUser) + } + + private async editDexTeamUsers( + data: Pick[], + sessionUser: SessionUser, + ): Promise[]> { + const dexPasswords = await listDexPasswords() + const updatedUsers: Pick[] = [] + + for (const userData of data) { + updatedUsers.push(await this.applyDexTeamUpdate(userData, sessionUser, dexPasswords)) + } + return updatedUsers + } + + private async applyDexTeamUpdate( + userData: Pick, + sessionUser: SessionUser, + dexPasswords: Password[], + ): Promise> { + if (!userData.id) { + throw new NotExistError(`User id is required`) + } + if (!userData.teams) { + throw new BadRequestError(`User teams is required`) + } + const match = dexPasswords.find((p) => p.userId === userData.id) + if (!match) { + throw new NotExistError(`User not found`) + } + const existingUser = dexPasswordToUser(match) + this.assertCanUpdateUserTeams(sessionUser, existingUser, userData.teams as string[]) + + const updatedUser: User = { ...existingUser, teams: userData.teams } + this.validateUserTeamsExist(updatedUser) + await updateDexPassword({ email: match.email, newGroups: deriveDexGroups(updatedUser) }) + return { id: updatedUser.id!, teams: updatedUser.teams || [] } + } + + private async editGitTeamUsers( + data: Pick[], + sessionUser: SessionUser, + ): Promise[]> { const aplRecords: AplRecord[] = [] const updatedUsers: Pick[] = [] for (const userData of data) { if (!userData.id) { - throw new NotExistError(`User ${userData.id} not found`) + throw new NotExistError(`User id is required`) } - const existingData = await getUserSecretData(userData.id, this.fileStore) - if (!existingData) { - throw new NotExistError(`User ${userData.id} not found`) + if (!userData.teams) { + throw new BadRequestError(`User teams is required`) } + const existingData = await this.requireUserSecretData(userData.id) const existingUser = userSecretDataToUser(existingData) - - if ( - !sessionUser.isPlatformAdmin && - !this.canTeamAdminUpdateUserTeams(sessionUser, existingUser, userData.teams as string[]) - ) { - throw new ForbiddenError( - 'Team admins are permitted to add or remove users only within the teams they manage. However, they cannot remove themselves or other team admins from those teams.', - ) - } + this.assertCanUpdateUserTeams(sessionUser, existingUser, userData.teams as string[]) const updatedUser: User = { ...existingUser, teams: userData.teams } const aplRecord = await this.saveUser(updatedUser) @@ -1439,7 +1606,6 @@ export default class OtomiStack { } await this.doDeployments(aplRecords) - return updatedUsers } diff --git a/src/utils/passwordUtils.test.ts b/src/utils/passwordUtils.test.ts new file mode 100644 index 00000000..03cfbbb6 --- /dev/null +++ b/src/utils/passwordUtils.test.ts @@ -0,0 +1,15 @@ +import bcrypt from 'bcryptjs' +import { hashPassword } from './passwordUtils' + +describe('hashPassword', () => { + it('returns a bcrypt hash that verifies against the original plaintext', async () => { + const hash = await hashPassword('Sup3r$ecret!') + expect(hash).not.toEqual('Sup3r$ecret!') + expect(await bcrypt.compare('Sup3r$ecret!', hash)).toBe(true) + }) + + it('produces a different hash each time (salted)', async () => { + const [a, b] = await Promise.all([hashPassword('same-input'), hashPassword('same-input')]) + expect(a).not.toEqual(b) + }) +}) diff --git a/src/utils/passwordUtils.ts b/src/utils/passwordUtils.ts new file mode 100644 index 00000000..edfe365a --- /dev/null +++ b/src/utils/passwordUtils.ts @@ -0,0 +1,12 @@ +import bcrypt from 'bcryptjs' + +const SALT_ROUNDS = 10 + +const BCRYPT_MAX_BYTES = 72 + +export async function hashPassword(plaintext: string): Promise { + if (Buffer.byteLength(plaintext, 'utf-8') > BCRYPT_MAX_BYTES) { + throw new Error(`Password must be at most ${BCRYPT_MAX_BYTES} bytes.`) + } + return bcrypt.hash(plaintext, SALT_ROUNDS) +} diff --git a/src/utils/userUtils.test.ts b/src/utils/userUtils.test.ts new file mode 100644 index 00000000..9dcfd598 --- /dev/null +++ b/src/utils/userUtils.test.ts @@ -0,0 +1,83 @@ +import { Password } from '@linode/dex-client-grpc' +import { deriveDexGroups, dexPasswordToUser } from './userUtils' + +function password(overrides: Partial = {}): Password { + return { + email: 'a@b.com', + hash: Buffer.from('hash'), + username: 'a', + userId: 'uuid-1', + groups: [], + ...overrides, + } +} + +describe('deriveDexGroups', () => { + it('returns no groups for a plain team member with no teams', () => { + expect(deriveDexGroups({ isPlatformAdmin: false, isTeamAdmin: false, teams: [] })).toEqual([]) + }) + + it('adds platform-admin for a platform admin', () => { + expect(deriveDexGroups({ isPlatformAdmin: true, isTeamAdmin: false, teams: [] })).toEqual(['platform-admin']) + }) + + it('adds team-admin for a team admin', () => { + expect(deriveDexGroups({ isPlatformAdmin: false, isTeamAdmin: true, teams: [] })).toEqual(['team-admin']) + }) + + it('adds team- for each team, matching the jwt.ts naming convention', () => { + expect(deriveDexGroups({ isPlatformAdmin: false, isTeamAdmin: false, teams: ['blue', 'red'] })).toEqual([ + 'team-blue', + 'team-red', + ]) + }) + + it('combines all group types', () => { + expect(deriveDexGroups({ isPlatformAdmin: true, isTeamAdmin: true, teams: ['blue'] })).toEqual([ + 'platform-admin', + 'team-admin', + 'team-blue', + ]) + }) +}) + +describe('dexPasswordToUser', () => { + it('maps a plain member with no groups', () => { + expect(dexPasswordToUser(password({ userId: 'u1', email: 'a@b.com', groups: [] }))).toMatchObject({ + id: 'u1', + email: 'a@b.com', + isPlatformAdmin: false, + isTeamAdmin: false, + teams: [], + }) + }) + + it('maps platform-admin and team-admin groups to their flags', () => { + expect(dexPasswordToUser(password({ groups: ['platform-admin', 'team-admin'] }))).toMatchObject({ + isPlatformAdmin: true, + isTeamAdmin: true, + teams: [], + }) + }) + + it('extracts team ids from team- groups, excluding the team-admin group itself', () => { + expect(dexPasswordToUser(password({ groups: ['team-admin', 'team-blue', 'team-red'] }))).toMatchObject({ + isTeamAdmin: true, + teams: ['blue', 'red'], + }) + }) + + it('strips the no-groups sentinel and treats it as no groups at all', () => { + expect(dexPasswordToUser(password({ groups: ['__no_groups__'] }))).toMatchObject({ + isPlatformAdmin: false, + isTeamAdmin: false, + teams: [], + }) + }) + + it('round-trips through deriveDexGroups', () => { + const original = { isPlatformAdmin: true, isTeamAdmin: false, teams: ['blue', 'red'] } + const roundTripped = dexPasswordToUser(password({ groups: deriveDexGroups(original) })) + expect(roundTripped).toMatchObject(original) + }) +}) diff --git a/src/utils/userUtils.ts b/src/utils/userUtils.ts index 6034bd86..487e2766 100644 --- a/src/utils/userUtils.ts +++ b/src/utils/userUtils.ts @@ -1,4 +1,6 @@ import axios from 'axios' +import { DEX_NO_GROUPS_SENTINEL } from 'src/clients/dexConstants' +import type { Password } from 'src/clients/dexClient' import { SealedSecretManifestResponse, User } from 'src/otomi-models' import { cleanEnv, ROOT_KEYCLOAK_USER } from 'src/validators' import { FileStore } from '../fileStore/file-store' @@ -79,6 +81,33 @@ export function userSecretDataToUser(data: UserSecretData): User { } as User } +export interface GroupSource { + isPlatformAdmin?: boolean + isTeamAdmin?: boolean + teams?: string[] +} + +export function deriveDexGroups(user: GroupSource): string[] { + const groups: string[] = [] + if (user.isPlatformAdmin) groups.push('platform-admin') + if (user.isTeamAdmin) groups.push('team-admin') + ;(user.teams || []).forEach((teamId) => groups.push(`team-${teamId}`)) + return groups +} + +export function dexPasswordToUser(password: Password): User { + const groups = password.groups.filter((group) => group !== DEX_NO_GROUPS_SENTINEL) + return { + id: password.userId, + email: password.email, + isPlatformAdmin: groups.includes('platform-admin'), + isTeamAdmin: groups.includes('team-admin'), + teams: groups + .filter((group) => group.startsWith('team-') && group !== 'team-admin') + .map((group) => group.substring(5)), + } as User +} + // gitea username blacklist and validation // https://github.com/go-gitea/gitea/blob/b8b856c7455166ef580d83a29b57c9b877d052b4/models/user/user.go#L563 const reservedUsernames = [ diff --git a/src/validators.ts b/src/validators.ts index 65bf8a27..8d926d65 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -69,6 +69,17 @@ export const SSO_JWKS_URI = str({ example: 'https://keycloak.example.com/realms/otomi/protocol/openid-connect/certs', devDefault: 'https://keycloak.example.com/realms/otomi/protocol/openid-connect/certs', }) +export const AUTH_PROVIDER = str({ + desc: 'Identity provider apl-api provisions users into: keycloak (default, no-op here) or dex', + choices: ['keycloak', 'dex'], + default: 'keycloak', +}) +export const DEX_GRPC_ADDRESS = str({ + desc: 'host:port of the Dex gRPC API. Required when AUTH_PROVIDER=dex.', + example: 'dex-grpc.dex.svc:5557', + devDefault: 'localhost:5557', + default: undefined, +}) export const JWT_AUDIENCE = str({ desc: 'Expected JWT audience', example: 'otomi',