From fc48f383a14eede5032dd08da1c13655f6fa4bc9 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Thu, 3 Sep 2026 13:23:19 +0200 Subject: [PATCH 01/22] feat: implement Dex user provisioning and password hashing functionality --- .gitignore | 1 + eslint.config.mjs | 1 + package-lock.json | 459 ++++++++++++++-- package.json | 12 +- src/clients/dexClient.integration.test.ts | 21 + src/clients/dexClient.test.ts | 128 +++++ src/clients/dexClient.ts | 157 ++++++ src/middleware/jwt.test.ts | 10 + src/middleware/jwt.ts | 3 + src/openapi/user.yaml | 2 - src/otomi-stack.test.ts | 274 ++++++++++ src/otomi-stack.ts | 267 +++++++--- src/proto/dex/api.proto | 604 ++++++++++++++++++++++ src/utils/passwordUtils.test.ts | 15 + src/utils/passwordUtils.ts | 7 + src/utils/userUtils.test.ts | 83 +++ src/utils/userUtils.ts | 34 ++ src/validators.ts | 11 + 18 files changed, 1962 insertions(+), 127 deletions(-) create mode 100644 src/clients/dexClient.integration.test.ts create mode 100644 src/clients/dexClient.test.ts create mode 100644 src/clients/dexClient.ts create mode 100644 src/proto/dex/api.proto create mode 100644 src/utils/passwordUtils.test.ts create mode 100644 src/utils/passwordUtils.ts create mode 100644 src/utils/userUtils.test.ts diff --git a/.gitignore b/.gitignore index 87164783e..052862281 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ kms.json* /vendors/client/ /src/generated-* +/src/generated/ /src/values-schema.yaml secrets.*.yaml.dec diff --git a/eslint.config.mjs b/eslint.config.mjs index 65bc56f6f..fb323bf7d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,6 +17,7 @@ export default defineConfig([ 'node_modules/*', 'vendors/*', 'src/generated-schema.ts', + 'src/generated/*', ]), { files: ['**/*.ts'], diff --git a/package-lock.json b/package-lock.json index 729e2880c..583acad58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "license": "ISC", "dependencies": { "@apidevtools/json-schema-ref-parser": "15.5.1", + "@bufbuild/protobuf": "^2.14.1", "@casl/ability": "6.8.1", + "@grpc/grpc-js": "^1.14.4", "@kubernetes/client-node": "1.4.0", "@linode/api-v4": "0.158.0", "@linode/kubeseal-encrypt": "^1.0.1", @@ -19,6 +21,7 @@ "@types/jsonwebtoken": "9.0.10", "async-retry": "^1.3.3", "axios": "1.19.0", + "bcryptjs": "^3.0.3", "clean-deep": "3.4.0", "cors": "2.8.6", "debug": "4.4.3", @@ -57,6 +60,7 @@ "@semantic-release/changelog": "6.0.3", "@semantic-release/git": "10.0.1", "@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", @@ -81,6 +85,7 @@ "git-branch-is": "4.0.0", "git-cz": "4.9.0", "globals": "17.9.0", + "grpc-tools": "^1.13.1", "husky": "9.1.7", "jest": "30.4.2", "jest-mock-extended": "4.0.1", @@ -100,6 +105,7 @@ "swagger-node-codegen": "1.6.3", "ts-jest": "^29.4.12", "ts-node": "^10.9.2", + "ts-proto": "^2.12.1", "tsc-alias": "1.9.1", "tsconfig-paths": "4.2.0", "tsx": "4.23.11", @@ -233,7 +239,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2072,6 +2077,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@bufbuild/protobuf": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.14.1.tgz", + "integrity": "sha512-agRJn3+EJDUe8AvxTx/LnHA/GErvLE62pSaSk7+MwFOtOv8eWBu/qCq2qoZjBjVZ3C2aiFJCveuSs17KMkYGOw==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@casl/ability": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.8.1.tgz", @@ -2752,7 +2763,6 @@ "integrity": "sha512-kLgLShnWADDVreKC63pBrWkcvxgZzFIfO34Jhx/SWfuOIA3cD8AXT+HjyuLfoGJ7mUb58hv2kUziKzEy4INb1w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=22.18.0" } @@ -2834,8 +2844,7 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.2.tgz", "integrity": "sha512-+ylGoKdwZ2sVOCOnU2Eq5wDZx+RaVX3HoKyNHGGsFvhSw6IidQ6tH/mAPKBDofViHJoWCPNlklE0lTr6MDG3QA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-dart": { "version": "2.3.2", @@ -2975,16 +2984,14 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.15.tgz", "integrity": "sha512-GJYnYKoD9fmo2OI0aySEGZOjThnx3upSUvV7mmqUu8oG+mGgzqm82P/f7OqsuvTaInZZwZbo+PwJQd/yHcyFIw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-html-symbol-entities": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz", "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -3182,8 +3189,7 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -3941,6 +3947,37 @@ "node": ">=14" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "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", @@ -4150,6 +4187,19 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -4410,7 +4460,6 @@ "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/environment": "30.4.1", "@jest/expect": "30.4.1", @@ -4696,6 +4745,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", @@ -4850,6 +4909,78 @@ "node": ">= 10" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", + "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "consola": "^3.2.3", + "detect-libc": "^2.0.0", + "https-proxy-agent": "^7.0.5", + "node-fetch": "^2.6.7", + "nopt": "^8.0.0", + "semver": "^7.5.3", + "tar": "^7.4.0" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -4945,7 +5076,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -5207,6 +5337,63 @@ "node": ">=12" } }, + "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", @@ -6280,6 +6467,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", @@ -6444,8 +6638,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/jsonfile": { "version": "6.1.4", @@ -6520,7 +6713,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -6694,7 +6886,6 @@ "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.66.0", "@typescript-eslint/types": "8.66.0", @@ -7263,7 +7454,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7419,7 +7609,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" } @@ -7428,7 +7617,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" }, @@ -7968,6 +8156,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/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -8155,7 +8352,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8369,6 +8565,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/case-anything": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz", + "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8486,6 +8695,16 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/ci-info": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", @@ -8667,7 +8886,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" }, @@ -8678,8 +8896,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", @@ -8860,6 +9077,16 @@ "proto-list": "~1.2.1" } }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -9405,7 +9632,6 @@ "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -10080,6 +10306,16 @@ "node": ">=8" } }, + "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==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -10236,6 +10472,29 @@ "node": ">=4" } }, + "node_modules/dprint-node": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/dprint-node/-/dprint-node-1.0.8.tgz", + "integrity": "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3" + } + }, + "node_modules/dprint-node/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -10839,7 +11098,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" @@ -10890,7 +11148,6 @@ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -11386,7 +11643,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11473,7 +11729,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12243,7 +12498,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.*" } @@ -12851,6 +13105,20 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/grpc-tools": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/grpc-tools/-/grpc-tools-1.13.1.tgz", + "integrity": "sha512-0sttMUxThNIkCTJq5qI0xXMz5zWqV2u3yG1kR3Sj9OokGIoyRBFjoInK9NyW7x5fH7knj48Roh1gq5xbl0VoDQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^2.0.0" + }, + "bin": { + "grpc_tools_node_protoc": "bin/protoc.js", + "grpc_tools_node_protoc_plugin": "bin/protoc_plugin.js" + } + }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -13624,7 +13892,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" } @@ -14137,7 +14404,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -15028,7 +15294,6 @@ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 10.16.0" } @@ -15693,6 +15958,12 @@ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "dev": true }, + "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.capitalize": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", @@ -15833,6 +16104,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/longest": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/longest/-/longest-2.0.1.tgz", @@ -15910,7 +16187,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -16198,6 +16474,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -19165,7 +19454,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -20350,7 +20638,6 @@ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -20469,6 +20756,29 @@ "dev": true, "license": "ISC" }, + "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", @@ -21183,7 +21493,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" } @@ -21488,7 +21797,6 @@ "integrity": "sha512-bxve7csK0/Txr++CkfrmV+X1r4jqiSOw2WsSad9E2S68R+ZfLBwDn8IceM8WfiOmKQIHgsQc1cNA8Dzg7U75pg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -22907,7 +23215,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", @@ -22943,8 +23250,7 @@ "node_modules/string-width/node_modules/emoji-regex": { "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 + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/string.prototype.padend": { "version": "3.1.3", @@ -23032,7 +23338,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" }, @@ -23330,6 +23635,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tar-fs": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.10.tgz", @@ -23355,6 +23677,16 @@ "streamx": "^2.15.0" } }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/temp-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", @@ -23599,7 +23931,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -23810,7 +24141,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -23849,6 +24179,42 @@ } } }, + "node_modules/ts-poet": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz", + "integrity": "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dprint-node": "^1.0.8" + } + }, + "node_modules/ts-proto": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.12.1.tgz", + "integrity": "sha512-IEFvmib22yVlXbagL/UXcfliCPilgqXs3J0/ajDhUslfezMRhhhZvwgc63W0ZrKxCj+8jMHxvrN6G13A5UGFVg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.10.2", + "case-anything": "^2.1.13", + "ts-poet": "^6.12.0", + "ts-proto-descriptors": "2.1.0" + }, + "bin": { + "protoc-gen-ts_proto": "protoc-gen-ts_proto" + } + }, + "node_modules/ts-proto-descriptors": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.1.0.tgz", + "integrity": "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.0.0" + } + }, "node_modules/tsc-alias": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.9.1.tgz", @@ -24074,7 +24440,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -24619,7 +24984,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", @@ -24687,7 +25051,6 @@ "version": "7.5.7", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz", "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==", - "peer": true, "engines": { "node": ">=8.3.0" }, @@ -24729,7 +25092,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" } @@ -24765,7 +25127,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -24793,7 +25154,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", @@ -24808,7 +25168,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 00d501fb6..25ddbfdf7 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ }, "dependencies": { "@apidevtools/json-schema-ref-parser": "15.5.1", + "@bufbuild/protobuf": "^2.14.1", "@casl/ability": "6.8.1", + "@grpc/grpc-js": "^1.14.4", "@kubernetes/client-node": "1.4.0", "@linode/api-v4": "0.158.0", "@linode/kubeseal-encrypt": "^1.0.1", @@ -18,6 +20,7 @@ "@types/jsonwebtoken": "9.0.10", "async-retry": "^1.3.3", "axios": "1.19.0", + "bcryptjs": "^3.0.3", "clean-deep": "3.4.0", "cors": "2.8.6", "debug": "4.4.3", @@ -57,6 +60,7 @@ "@semantic-release/changelog": "6.0.3", "@semantic-release/git": "10.0.1", "@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", @@ -81,6 +85,7 @@ "git-branch-is": "4.0.0", "git-cz": "4.9.0", "globals": "17.9.0", + "grpc-tools": "^1.13.1", "husky": "9.1.7", "jest": "30.4.2", "jest-mock-extended": "4.0.1", @@ -100,6 +105,7 @@ "swagger-node-codegen": "1.6.3", "ts-jest": "^29.4.12", "ts-node": "^10.9.2", + "ts-proto": "^2.12.1", "tsc-alias": "1.9.1", "tsconfig-paths": "4.2.0", "tsx": "4.23.11", @@ -119,6 +125,7 @@ }, "main": "dist/src/app.js", "name": "@redkubes/otomi-api", + "name": "@redkubes/otomi-api", "publishConfig": { "private": true, "registry": "https://npm.pkg.github.com" @@ -128,9 +135,10 @@ "url": "git+https://github.com/redkubes/otomi-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", + "build": "npm run clean && npm run build:models && npm run gen:dex-client && 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", "build:models": "npm run build:spec && openapi-typescript src/generated-schema.json -o src/generated-schema.ts --default-non-nullable false", "build:spec": "tsx src/build-spec.ts", + "gen:dex-client": "mkdir -p src/generated/dex && grpc_tools_node_protoc --plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=src/generated/dex --ts_proto_opt=outputServices=grpc-js,esModuleInterop=true,env=node,outputJsonMethods=false,outputClientImpl=true -I src/proto/dex src/proto/dex/api.proto", "clean": "rm -rf dist >/dev/null", "cz": "git-cz", "cz:retry": "git-cz --retry", @@ -141,7 +149,7 @@ "lint:ts": "eslint --ext ts .", "lint:fix": "eslint --ext ts --fix .", "lint-staged": "lint-staged", - "postinstall": "npm run build:models", + "postinstall": "npm run build:models && npm run gen:dex-client", "pre-release:client": "npm version prerelease --preid rc --no-commit-hooks --no-git-tag-version && bin/release-client.sh", "release": "standard-version", "schema:sync": "APL_CORE_PATH=${APL_CORE_PATH:-../apl-core} && cp \"$APL_CORE_PATH/values-schema.yaml\" src/values-schema.yaml && echo \"Schema synced from $APL_CORE_PATH\"", diff --git a/src/clients/dexClient.integration.test.ts b/src/clients/dexClient.integration.test.ts new file mode 100644 index 000000000..1822f2e39 --- /dev/null +++ b/src/clients/dexClient.integration.test.ts @@ -0,0 +1,21 @@ +import { createDexPassword, deleteDexPassword, updateDexPassword } from './dexClient' + +const describeIfDexAvailable = process.env.DEX_GRPC_ADDRESS ? describe : describe.skip + +describeIfDexAvailable('dexClient integration (requires a running fork-built Dex)', () => { + const email = `dex-integration-test-${Date.now()}@example.com` + + it('creates, updates groups, and deletes a password record end to end', async () => { + await createDexPassword({ + id: 'integration-test-uuid', + email, + passwordHash: '$2a$10$abcdefghijklmnopqrstuuVGm5ZQeXk6b2ZQeXk6b2ZQeXk6b', + username: 'dex-integration-test', + groups: ['team-blue'], + }) + + await updateDexPassword({ email, newGroups: ['team-blue', 'team-admin'] }) + + await deleteDexPassword(email) + }) +}) diff --git a/src/clients/dexClient.test.ts b/src/clients/dexClient.test.ts new file mode 100644 index 000000000..eee683f93 --- /dev/null +++ b/src/clients/dexClient.test.ts @@ -0,0 +1,128 @@ +const mockCreatePassword = jest.fn() +const mockUpdatePassword = jest.fn() +const mockDeletePassword = jest.fn() + +jest.mock('src/generated/dex/api', () => ({ + DexClient: jest.fn().mockImplementation(() => ({ + createPassword: mockCreatePassword, + updatePassword: mockUpdatePassword, + deletePassword: mockDeletePassword, + })), +})) + +process.env.DEX_GRPC_ADDRESS = 'localhost:5557' + +import { + createDexPassword, + DEX_NO_GROUPS_SENTINEL, + 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 DexProvisionError 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(DexProvisionError) + }) + + 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 DexProvisionError when the record is not found', async () => { + mockUpdatePassword.mockImplementation((_req, cb) => cb(null, { notFound: true })) + + await expect(updateDexPassword({ email: 'ghost@b.com' })).rejects.toBeInstanceOf(DexProvisionError) + }) + + 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 000000000..cc670c12e --- /dev/null +++ b/src/clients/dexClient.ts @@ -0,0 +1,157 @@ +import { ChannelCredentials, ServiceError } from '@grpc/grpc-js' +import retry from 'async-retry' +import { CreatePasswordResp, DeletePasswordResp, DexClient, Password, UpdatePasswordResp } from 'src/generated/dex/api' +import { cleanEnv, DEX_GRPC_ADDRESS } from 'src/validators' + +export type { Password } + +const env = cleanEnv({ DEX_GRPC_ADDRESS }) + +// Dex's UpdatePassword handler (server/apiserver/passwords.go in the fork) only replaces +// stored groups when the incoming field is non-nil (`if req.NewGroups != nil`). Proto3 repeated +// fields carry no wire presence, so an empty array and an omitted field both unmarshal to nil on +// the server - an empty groups list from us is silently ignored instead of clearing the user's +// groups. Sending this sentinel instead of an empty array gives the field a non-nil, one-element +// value, so a demotion to "no groups" actually reaches Dex. getUser() in src/middleware/jwt.ts +// strips it back out before deriving roles/teams from a token. +export const DEX_NO_GROUPS_SENTINEL = '__no_groups__' + +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) { + // TODO(#3536): createInsecure() is a known temporary gap pending TLS wiring in apl-core. + client = new DexClient(env.DEX_GRPC_ADDRESS, ChannelCredentials.createInsecure()) + } + return client +} + +function callWithRetry(fn: () => Promise): Promise { + return retry(fn, { retries: 3, minTimeout: 200 }) +} + +export interface CreateDexPasswordInput { + id: string + email: string + passwordHash: string + username: string + groups: string[] +} + +export async function createDexPassword(input: CreateDexPasswordInput): Promise { + const dex = getDexClient() + await callWithRetry( + () => + 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 DexProvisionError(`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 callWithRetry( + () => + 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 DexProvisionError(`Dex has no password record for ${input.email}`)) + return + } + resolve() + }, + ) + }), + ) +} + +export async function listDexPasswords(): Promise { + const dex = getDexClient() + return callWithRetry( + () => + 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 callWithRetry( + () => + 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() + }) + }), + ) +} diff --git a/src/middleware/jwt.test.ts b/src/middleware/jwt.test.ts index 042e0220a..7b0523f33 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/dexClient' 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 2968d1623..4398f4c8f 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/dexClient' 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 diff --git a/src/openapi/user.yaml b/src/openapi/user.yaml index c1e6a740a..7622dd085 100644 --- a/src/openapi/user.yaml +++ b/src/openapi/user.yaml @@ -118,6 +118,4 @@ User: description: The initial password of the user required: - email - - firstName - - lastName type: object diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index 09fa11f1f..b03edf0c0 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -61,6 +61,20 @@ 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([]) +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), + DEX_NO_GROUPS_SENTINEL: '__no_groups__', + DexProvisionError: class DexProvisionError extends Error {}, +})) + jest.mock('./utils/sealedSecretUtils', () => { const originalModule = jest.requireActual('./utils/sealedSecretUtils') return { @@ -564,6 +578,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', () => { @@ -801,6 +823,258 @@ 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 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('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') + 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 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') + 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 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 6f6362b79..c3283813f 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -105,6 +105,7 @@ import { import { deepQuote } from 'src/utils/yamlUtils' import { API_NAMESPACE, + AUTH_PROVIDER, CATALOG_CACHE_PATH, cleanEnv, CUSTOM_ROOT_CA, @@ -146,9 +147,17 @@ import { mergeCanaryServices, setApiStatusInConfigMap, toK8sService, + UserSecretData, watchPodUntilRunning, } from './k8s-operations' import CloudTty from './tty' +import { + createDexPassword, + deleteDexPassword, + listDexPasswords, + Password, + updateDexPassword, +} from './clients/dexClient' import { extractRepositoryRefs, getAuthenticatedGitClient, @@ -157,6 +166,7 @@ import { } from './utils/codeRepoUtils' import { isKnativeSupported } from './utils/k8sUtils' import { getV1ObjectFromApl } from './utils/manifests' +import { hashPassword } from './utils/passwordUtils' import { createUserSealedSecret, encryptAndMergeSecrets, @@ -170,6 +180,8 @@ import { sealedSecretManifest, } from './utils/sealedSecretUtils' import { + deriveDexGroups, + dexPasswordToUser, getKeycloakUsers, getUserSecretData, isValidUsername, @@ -187,6 +199,7 @@ const debug = Debug('otomi:otomi-stack') const env = cleanEnv({ API_NAMESPACE, + AUTH_PROVIDER, CATALOG_CACHE_PATH, CUSTOM_ROOT_CA, DEFAULT_PLATFORM_ADMIN_EMAIL, @@ -1230,17 +1243,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() } @@ -1251,7 +1272,24 @@ export default class OtomiStack { throw new HttpError(400, error as string) } - const initialPassword = generatePassword({ + const initialPassword = this.generateInitialPassword() + 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 generateInitialPassword(): string { + return generatePassword({ length: 16, numbers: true, symbols: '!@#$%&*', @@ -1259,55 +1297,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 { @@ -1315,25 +1374,35 @@ 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`) - } + return env.AUTH_PROVIDER === 'dex' ? this.editDexUser(id, data) : this.editGitUser(id, data) + } - const existingUser = userSecretDataToUser(existingData) + private async editDexUser(id: string, data: User): Promise { + const { match, user: existingUser } = await this.lookupDexUser(id) + const user: User = { ...existingUser, ...data, id } + this.validateUserTeamsExist(user) - const user: User = { - ...existingUser, - ...data, - id, - initialPassword: existingUser.initialPassword, - } + // 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. + const newHash = data.initialPassword ? await hashPassword(data.initialPassword) : undefined + await updateDexPassword({ + // The lookup key: Dex's UpdatePasswordReq documents email as immutable, so an email change + // in `data` is not something this call can express - it targets the record as it exists today. + 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 } @@ -1349,19 +1418,31 @@ 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 { + const { match } = await this.lookupDexUser(id) + if (match.email === env.DEFAULT_PLATFORM_ADMIN_EMAIL) { + throw new ForbiddenError('Cannot delete the default platform admin user') + } + // Dex is the only place a dex-provisioned user's record lives — no SealedSecret to remove. + await deleteDexPassword(match.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) - // Also remove legacy AplUser file if it exists const legacyFilePath = getResourceFilePath('AplUser', id) await this.git.removeFile(legacyFilePath) this.fileStore.delete(legacyFilePath) @@ -1397,6 +1478,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, @@ -1405,6 +1494,50 @@ 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) + } + + // Dex has no Git counterpart to keep in sync, so there's no two-pass ordering to worry about + // here — each update either lands in Dex or the whole request rejects. + 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 ${userData.id} not found`) + } + const match = dexPasswords.find((p) => p.userId === userData.id) + if (!match) { + throw new NotExistError(`User ${userData.id} not found`) + } + const existingUser = dexPasswordToUser(match) + this.assertCanUpdateUserTeams(sessionUser, existingUser, userData.teams as string[]) + + const updatedUser: User = { ...existingUser, teams: userData.teams } + 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[] = [] @@ -1412,20 +1545,9 @@ export default class OtomiStack { if (!userData.id) { throw new NotExistError(`User ${userData.id} not found`) } - const existingData = await getUserSecretData(userData.id, this.fileStore) - if (!existingData) { - throw new NotExistError(`User ${userData.id} not found`) - } + 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) @@ -1434,7 +1556,6 @@ export default class OtomiStack { } await this.doDeployments(aplRecords) - return updatedUsers } diff --git a/src/proto/dex/api.proto b/src/proto/dex/api.proto new file mode 100644 index 000000000..bde1d5c08 --- /dev/null +++ b/src/proto/dex/api.proto @@ -0,0 +1,604 @@ +syntax = "proto3"; + +package api; + +option java_package = "com.coreos.dex.api"; +option go_package = "github.com/dexidp/dex/api/v2;api"; + +// Client represents an OAuth2 client. +message Client { + string id = 1; + string secret = 2; + repeated string redirect_uris = 3; + repeated string trusted_peers = 4; + bool public = 5; + string name = 6; + string logo_url = 7; + repeated string allowed_connectors = 8; + repeated string sso_shared_with = 9; + // Where dex POSTs a logout token when a session this client took part in + // ends, per OIDC Back-Channel Logout 1.0. Empty means the client is not + // notified. + string backchannel_logout_uri = 10; + // Where the browser may be sent after an RP-initiated logout. A + // post_logout_redirect_uri that is not listed here is refused. + repeated string post_logout_redirect_uris = 11; + // Whether this client's refresh tokens outlive the browser session that + // issued them: "standalone" (the default) or "session". + string refresh_token_lifetime = 12; +} + +// ClientInfo represents an OAuth2 client without sensitive information. +message ClientInfo { + string id = 1; + repeated string redirect_uris = 2; + repeated string trusted_peers = 3; + bool public = 4; + string name = 5; + string logo_url = 6; + repeated string allowed_connectors = 7; + repeated string sso_shared_with = 8; + string backchannel_logout_uri = 9; + repeated string post_logout_redirect_uris = 10; + string refresh_token_lifetime = 11; +} + +// GetClientReq is a request to retrieve client details. +message GetClientReq { + // The ID of the client. + string id = 1; +} + +// GetClientResp returns the client details. +message GetClientResp { + Client client = 1; +} + +// CreateClientReq is a request to make a client. +message CreateClientReq { + Client client = 1; +} + +// CreateClientResp returns the response from creating a client. +message CreateClientResp { + bool already_exists = 1; + Client client = 2; +} + +// DeleteClientReq is a request to delete a client. +message DeleteClientReq { + // The ID of the client. + string id = 1; +} + +// DeleteClientResp determines if the client is deleted successfully. +message DeleteClientResp { + bool not_found = 1; +} + +// UpdateClientReq is a request to update an existing client. +message UpdateClientReq { + string id = 1; + repeated string redirect_uris = 2; + repeated string trusted_peers = 3; + string name = 4; + string logo_url = 5; + repeated string allowed_connectors = 6; + repeated string sso_shared_with = 7; + // Optional so that an empty value clears the URI. Without explicit presence + // a client could be given a back-channel endpoint but never relieved of one, + // leaving dex posting logout tokens at something that no longer exists. + optional string backchannel_logout_uri = 8; + repeated string post_logout_redirect_uris = 9; + // Optional for the same reason as backchannel_logout_uri: an empty value has + // to be tellable apart from "leave it alone" to put a client back on the + // default lifetime. + optional string refresh_token_lifetime = 10; +} + +// UpdateClientResp returns the response from updating a client. +message UpdateClientResp { + bool not_found = 1; +} + +// ListClientReq is a request to enumerate clients. +message ListClientReq {} + +// ListClientResp returns a list of clients. +message ListClientResp { + repeated ClientInfo clients = 1; +} + +// TODO(ericchiang): expand this. + +// Password is an email for password mapping managed by the storage. +message Password { + string email = 1; + + // Currently we do not accept plain text passwords. Could be an option in the future. + bytes hash = 2; + string username = 3; + string user_id = 4; + repeated string groups = 5; +} + +// CreatePasswordReq is a request to make a password. +message CreatePasswordReq { + Password password = 1; +} + +// CreatePasswordResp returns the response from creating a password. +message CreatePasswordResp { + bool already_exists = 1; +} + +// UpdatePasswordReq is a request to modify an existing password. +message UpdatePasswordReq { + // The email used to lookup the password. This field cannot be modified + string email = 1; + bytes new_hash = 2; + string new_username = 3; + repeated string new_groups = 4; +} + +// UpdatePasswordResp returns the response from modifying an existing password. +message UpdatePasswordResp { + bool not_found = 1; +} + +// DeletePasswordReq is a request to delete a password. +message DeletePasswordReq { + string email = 1; +} + +// DeletePasswordResp returns the response from deleting a password. +message DeletePasswordResp { + bool not_found = 1; +} + +// ListPasswordReq is a request to enumerate passwords. +message ListPasswordReq {} + +// ListPasswordResp returns a list of passwords. +message ListPasswordResp { + repeated Password passwords = 1; +} + +// Connector is a strategy used by Dex for authenticating a user against another identity provider +message Connector { + string id = 1; + string type = 2; + string name = 3; + bytes config = 4; + repeated string grant_types = 5; +} + +// CreateConnectorReq is a request to make a connector. +message CreateConnectorReq { + Connector connector = 1; +} + +// CreateConnectorResp returns the response from creating a connector. +message CreateConnectorResp { + bool already_exists = 1; +} + +// GrantTypes wraps a list of grant types to distinguish between +// "not specified" (no update) and "empty list" (unrestricted). +message GrantTypes { + repeated string grant_types = 1; +} + +// UpdateConnectorReq is a request to modify an existing connector. +message UpdateConnectorReq { + // The id used to lookup the connector. This field cannot be modified + string id = 1; + string new_type = 2; + string new_name = 3; + bytes new_config = 4; + // If set, updates the connector's allowed grant types. + // An empty grant_types list means unrestricted (all grant types allowed). + // If not set (null), grant types are not modified. + GrantTypes new_grant_types = 5; +} + +// UpdateConnectorResp returns the response from modifying an existing connector. +message UpdateConnectorResp { + bool not_found = 1; +} + +// DeleteConnectorReq is a request to delete a connector. +message DeleteConnectorReq { + string id = 1; +} + +// DeleteConnectorResp returns the response from deleting a connector. +message DeleteConnectorResp { + bool not_found = 1; +} + +// ListConnectorReq is a request to enumerate connectors. +message ListConnectorReq {} + +// ListConnectorResp returns a list of connectors. +message ListConnectorResp { + repeated Connector connectors = 1; +} + +// VersionReq is a request to fetch version info. +message VersionReq {} + +// VersionResp holds the version info of components. +message VersionResp { + // Semantic version of the server. + string server = 1; + // Numeric version of the API. It increases every time a new call is added to the API. + // Clients should use this info to determine if the server supports specific features. + int32 api = 2; +} + +// DiscoveryReq is a request to fetch discover information. +message DiscoveryReq {} + +//DiscoverResp holds the version oidc disovery info. +message DiscoveryResp { + string issuer = 1; + string authorization_endpoint = 2; + string token_endpoint = 3; + string jwks_uri = 4; + string userinfo_endpoint = 5; + string device_authorization_endpoint = 6; + string introspection_endpoint = 7; + repeated string grant_types_supported = 8; + repeated string response_types_supported = 9; + repeated string subject_types_supported = 10; + repeated string id_token_signing_alg_values_supported = 11; + repeated string code_challenge_methods_supported = 12; + repeated string scopes_supported = 13; + repeated string token_endpoint_auth_methods_supported = 14; + repeated string claims_supported = 15; +} + +// RefreshTokenRef contains the metadata for a refresh token that is managed by the storage. +message RefreshTokenRef { + // ID of the refresh token. + string id = 1; + string client_id = 2; + int64 created_at = 5; + int64 last_used = 6; +} + +// ListRefreshReq is a request to enumerate the refresh tokens of a user. +message ListRefreshReq { + // The "sub" claim returned in the ID Token. + string user_id = 1; +} + +// ListRefreshResp returns a list of refresh tokens for a user. +message ListRefreshResp { + repeated RefreshTokenRef refresh_tokens = 1; +} + +// RevokeRefreshReq is a request to revoke the refresh token of the user-client pair. +message RevokeRefreshReq { + // The "sub" claim returned in the ID Token. + string user_id = 1; + string client_id = 2; +} + +// RevokeRefreshResp determines if the refresh token is revoked successfully. +message RevokeRefreshResp { + // Set to true is refresh token was not found and token could not be revoked. + bool not_found = 1; +} + +message VerifyPasswordReq { + string email = 1; + string password = 2; +} + +message VerifyPasswordResp { + bool verified = 1; + bool not_found = 2; +} + +// ClientAuthState represents authentication state for a specific client within a session. +// The user_id and connector_id are on the parent AuthSession message. +message ClientAuthState { + string client_id = 1; + int64 authenticated_at = 2; + int64 last_activity = 3; + int64 last_token_issued_at = 4; + // Whether this client was reached through another client's SSO sharing rather + // than by authenticating directly. + bool via_sso = 5; +} + +// AuthSession represents a user's authentication session. +message AuthSession { + // Random identifier of the session, published to clients as the "sid" claim. One + // signed-in browser is one session, so a user has as many as they have devices. + string id = 10; + string user_id = 1; + string connector_id = 2; + repeated ClientAuthState client_states = 3; + int64 created_at = 4; + int64 last_activity = 5; + string ip_address = 6; + string user_agent = 7; + int64 absolute_expiry = 8; + int64 idle_expiry = 9; +} + +// GetAuthSessionReq is a request to retrieve an auth session. +message GetAuthSessionReq { + string id = 1; +} + +// GetAuthSessionResp returns the auth session details. +message GetAuthSessionResp { + AuthSession session = 1; +} + +// ListAuthSessionsReq is a request to list auth sessions. +message ListAuthSessionsReq { + // Optional filter: if set, only sessions for this user are returned. + string user_id = 1; + // Optional filter: if set, only sessions from this connector are returned. + string connector_id = 2; +} + +// ListAuthSessionsResp returns a list of auth sessions. +message ListAuthSessionsResp { + repeated AuthSession sessions = 1; +} + +// DeleteAuthSessionReq is a request to delete an auth session. +// Deleting a session also revokes all associated refresh tokens (consistent with logout behavior). +message DeleteAuthSessionReq { + string id = 1; +} + +// DeleteAuthSessionResp returns the result of deleting an auth session. +message DeleteAuthSessionResp { + bool not_found = 1; +} + +// TerminateSessionsByConnectorReq is a request to terminate all sessions for a connector. +// Use when connector configuration changes or is removed. Also revokes associated refresh tokens. +message TerminateSessionsByConnectorReq { + string connector_id = 1; +} + +// TerminateSessionsByConnectorResp returns the count of terminated sessions. +message TerminateSessionsByConnectorResp { + int64 sessions_terminated = 1; +} + +// TerminateSessionsByUserReq is a request to terminate all sessions for a user. +// Use for account compromise scenarios. Also revokes associated refresh tokens. +message TerminateSessionsByUserReq { + string user_id = 1; +} + +// TerminateSessionsByUserResp returns the count of terminated sessions. +message TerminateSessionsByUserResp { + int64 sessions_terminated = 1; +} + +// ConsentEntry represents approved scopes for a single client. +message ConsentEntry { + string client_id = 1; + repeated string scopes = 2; +} + +// MFASecret represents metadata of an enrolled MFA authenticator. +// The actual secret value is never exposed through the admin API. +message MFASecret { + string authenticator_id = 1; + string type = 2; + bool confirmed = 3; + int64 created_at = 4; +} + +// WebAuthnCredential represents metadata of a registered WebAuthn credential. +// The public key is never exposed through the admin API. +message WebAuthnCredential { + bytes credential_id = 1; + string attestation_type = 2; + bytes aaguid = 3; + uint32 sign_count = 4; + bool clone_warning = 5; + repeated string transport = 6; + bool backup_eligible = 7; + bool backup_state = 8; + string display_name = 9; + int64 created_at = 10; +} + +// MFADeviceInfo groups MFA secret and WebAuthn credentials for one authenticator. +message MFADeviceInfo { + string authenticator_id = 1; + MFASecret mfa_secret = 2; + repeated WebAuthnCredential webauthn_credentials = 3; +} + +// UserIdentity represents persistent per-user identity data. +message UserIdentity { + string user_id = 1; + string connector_id = 2; + string email = 3; + bool email_verified = 4; + string username = 5; + repeated string groups = 6; + repeated ConsentEntry consents = 7; + repeated MFADeviceInfo mfa_devices = 8; + int64 created_at = 9; + int64 last_login = 10; + int64 blocked_until = 11; +} + +// GetUserIdentityReq is a request to retrieve a user identity. +message GetUserIdentityReq { + string user_id = 1; + string connector_id = 2; +} + +// GetUserIdentityResp returns the user identity details. +message GetUserIdentityResp { + UserIdentity identity = 1; +} + +// ListUserIdentitiesReq is a request to list user identities. +message ListUserIdentitiesReq {} + +// ListUserIdentitiesResp returns a list of user identities. +message ListUserIdentitiesResp { + repeated UserIdentity identities = 1; +} + +// DeleteUserIdentityReq is a request to delete a user identity. +// This is a full data purge for GDPR compliance and account deletion. +// It cascades to: auth session, all refresh tokens, offline sessions, the +// password record (matched by the identity's email), and the identity itself. +message DeleteUserIdentityReq { + string user_id = 1; + string connector_id = 2; +} + +// DeleteUserIdentityResp returns the result of deleting a user identity. +message DeleteUserIdentityResp { + bool not_found = 1; +} + +// ResetMFAReq is a request to clear all MFA secrets and WebAuthn credentials for a user. +// Use when a user has lost access to all their MFA devices. +message ResetMFAReq { + string user_id = 1; + string connector_id = 2; +} + +// ResetMFAResp returns the result of resetting MFA. +message ResetMFAResp { + bool not_found = 1; +} + +// ListMFADevicesReq is a request to list registered MFA authenticators for a user. +message ListMFADevicesReq { + string user_id = 1; + string connector_id = 2; +} + +// ListMFADevicesResp returns MFA device information. +// Secret values and public keys are never included in the response. +message ListMFADevicesResp { + repeated MFADeviceInfo devices = 1; +} + +// DeleteWebAuthnCredentialReq is a request to delete a specific WebAuthn credential. +// Use when a user has lost or wants to deregister a specific security key. +message DeleteWebAuthnCredentialReq { + string user_id = 1; + string connector_id = 2; + bytes credential_id = 3; +} + +// DeleteWebAuthnCredentialResp returns the result of deleting a WebAuthn credential. +message DeleteWebAuthnCredentialResp { + bool not_found = 1; +} + +// DeleteMFASecretReq is a request to delete a specific MFA authenticator secret. +// Also removes any associated WebAuthn credentials for the same authenticator. +message DeleteMFASecretReq { + string user_id = 1; + string connector_id = 2; + string authenticator_id = 3; +} + +// DeleteMFASecretResp returns the result of deleting an MFA secret. +message DeleteMFASecretResp { + bool not_found = 1; +} + +// RevokeConsentReq is a request to revoke consent for a specific client. +// The user will see the consent screen again on next authorization. +message RevokeConsentReq { + string user_id = 1; + string connector_id = 2; + string client_id = 3; +} + +// RevokeConsentResp returns the result of revoking consent. +message RevokeConsentResp { + bool not_found = 1; +} + +// Dex represents the dex gRPC service. +service Dex { + // GetClient gets a client. + rpc GetClient(GetClientReq) returns (GetClientResp) {}; + // CreateClient creates a client. + rpc CreateClient(CreateClientReq) returns (CreateClientResp) {}; + // UpdateClient updates an existing client + rpc UpdateClient(UpdateClientReq) returns (UpdateClientResp) {}; + // DeleteClient deletes the provided client. + rpc DeleteClient(DeleteClientReq) returns (DeleteClientResp) {}; + // ListClients lists all client entries. + rpc ListClients(ListClientReq) returns (ListClientResp) {}; + // CreatePassword creates a password. + rpc CreatePassword(CreatePasswordReq) returns (CreatePasswordResp) {}; + // UpdatePassword modifies existing password. + rpc UpdatePassword(UpdatePasswordReq) returns (UpdatePasswordResp) {}; + // DeletePassword deletes the password. + rpc DeletePassword(DeletePasswordReq) returns (DeletePasswordResp) {}; + // ListPassword lists all password entries. + rpc ListPasswords(ListPasswordReq) returns (ListPasswordResp) {}; + // CreateConnector creates a connector. + rpc CreateConnector(CreateConnectorReq) returns (CreateConnectorResp) {}; + // UpdateConnector modifies existing connector. + rpc UpdateConnector(UpdateConnectorReq) returns (UpdateConnectorResp) {}; + // DeleteConnector deletes the connector. + rpc DeleteConnector(DeleteConnectorReq) returns (DeleteConnectorResp) {}; + // ListConnectors lists all connector entries. + rpc ListConnectors(ListConnectorReq) returns (ListConnectorResp) {}; + // GetVersion returns version information of the server. + rpc GetVersion(VersionReq) returns (VersionResp) {}; + // GetDiscovery returns discovery information of the server. + rpc GetDiscovery(DiscoveryReq) returns (DiscoveryResp) {}; + // ListRefresh lists all the refresh token entries for a particular user. + rpc ListRefresh(ListRefreshReq) returns (ListRefreshResp) {}; + // RevokeRefresh revokes the refresh token for the provided user-client pair. + // + // Note that each user-client pair can have only one refresh token at a time. + rpc RevokeRefresh(RevokeRefreshReq) returns (RevokeRefreshResp) {}; + // VerifyPassword returns whether a password matches a hash for a specific email or not. + rpc VerifyPassword(VerifyPasswordReq) returns (VerifyPasswordResp) {}; + // GetAuthSession returns an auth session by its ID. + rpc GetAuthSession(GetAuthSessionReq) returns (GetAuthSessionResp) {}; + // ListAuthSessions lists auth sessions, optionally filtered by user and connector. + rpc ListAuthSessions(ListAuthSessionsReq) returns (ListAuthSessionsResp) {}; + // DeleteAuthSession deletes an auth session and revokes associated refresh tokens. + rpc DeleteAuthSession(DeleteAuthSessionReq) returns (DeleteAuthSessionResp) {}; + // TerminateSessionsByConnector terminates all sessions for a connector and revokes associated refresh tokens. + rpc TerminateSessionsByConnector(TerminateSessionsByConnectorReq) returns (TerminateSessionsByConnectorResp) {}; + // TerminateSessionsByUser terminates all sessions for a user and revokes associated refresh tokens. + rpc TerminateSessionsByUser(TerminateSessionsByUserReq) returns (TerminateSessionsByUserResp) {}; + // GetUserIdentity returns a user identity by user and connector ID. + rpc GetUserIdentity(GetUserIdentityReq) returns (GetUserIdentityResp) {}; + // ListUserIdentities lists all user identities. + rpc ListUserIdentities(ListUserIdentitiesReq) returns (ListUserIdentitiesResp) {}; + // DeleteUserIdentity performs a full data purge for GDPR compliance: deletes the identity, + // auth session, refresh tokens, and offline sessions. + rpc DeleteUserIdentity(DeleteUserIdentityReq) returns (DeleteUserIdentityResp) {}; + // ResetMFA clears all MFA secrets and WebAuthn credentials for a user. + rpc ResetMFA(ResetMFAReq) returns (ResetMFAResp) {}; + // ListMFADevices lists registered MFA authenticators for a user. + rpc ListMFADevices(ListMFADevicesReq) returns (ListMFADevicesResp) {}; + // DeleteWebAuthnCredential deletes a specific WebAuthn credential. + rpc DeleteWebAuthnCredential(DeleteWebAuthnCredentialReq) returns (DeleteWebAuthnCredentialResp) {}; + // DeleteMFASecret deletes a specific MFA authenticator and its associated WebAuthn credentials. + rpc DeleteMFASecret(DeleteMFASecretReq) returns (DeleteMFASecretResp) {}; + // RevokeConsent revokes consent for a specific client. + rpc RevokeConsent(RevokeConsentReq) returns (RevokeConsentResp) {}; +} diff --git a/src/utils/passwordUtils.test.ts b/src/utils/passwordUtils.test.ts new file mode 100644 index 000000000..03cfbbb66 --- /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 000000000..0575c3753 --- /dev/null +++ b/src/utils/passwordUtils.ts @@ -0,0 +1,7 @@ +import bcrypt from 'bcryptjs' + +const SALT_ROUNDS = 10 + +export async function hashPassword(plaintext: string): Promise { + 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 000000000..748f58eb3 --- /dev/null +++ b/src/utils/userUtils.test.ts @@ -0,0 +1,83 @@ +import { Password } from 'src/generated/dex/api' +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 6034bd866..d46059b26 100644 --- a/src/utils/userUtils.ts +++ b/src/utils/userUtils.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { DEX_NO_GROUPS_SENTINEL, 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 +80,39 @@ export function userSecretDataToUser(data: UserSecretData): User { } as User } +export interface GroupSource { + isPlatformAdmin?: boolean + isTeamAdmin?: boolean + teams?: string[] +} + +// Mirrors the naming convention read back out of Keycloak-issued tokens +// in src/middleware/jwt.ts's getUser() — platform-admin / team-admin / team-. +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 +} + +// Inverse of deriveDexGroups: when AUTH_PROVIDER=dex, Dex's own password store is the only +// place a user record lives (see otomi-stack.ts createUser/getUser/editUser/editTeamUsers/ +// deleteUser) — this reconstructs a User-shaped object from the groups Dex hands back over +// ListPasswords. Dex carries no firstName/lastName/initialPassword, so those come back undefined. +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 54aa95ca3..a80209b26 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', From 3165070557c14bd879eaf505e9915afcdbbea3a2 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Thu, 3 Sep 2026 13:36:51 +0200 Subject: [PATCH 02/22] fix: add proto file to docker --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 671922a02..edebae9b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,9 +10,10 @@ WORKDIR /app # Install dependencies before copying the full source code to take advantage of Docker layer caching COPY package*.json ./ -# Needed for postinstall (build:models) during npm ci +# Needed for postinstall (build:models, gen:dex-client) during npm ci COPY src/build-spec.ts ./src/build-spec.ts COPY src/openapi ./src/openapi +COPY src/proto ./src/proto RUN npm ci COPY . .* ./ From a87e08756dc411de91092e8e87a2b4586efcb601 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Tue, 8 Sep 2026 16:09:25 +0200 Subject: [PATCH 03/22] feat: allow to set initialPassword --- src/openapi/user.yaml | 2 +- src/otomi-stack.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++ src/otomi-stack.ts | 13 ++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/openapi/user.yaml b/src/openapi/user.yaml index 7622dd085..1c6ba39b9 100644 --- a/src/openapi/user.yaml +++ b/src/openapi/user.yaml @@ -115,7 +115,7 @@ User: uniqueItems: true initialPassword: type: string - description: The initial password of the user + description: The initial password of the user. With Dex as issuer, an admin may set this on create; otherwise one is generated. required: - email type: object diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index b03edf0c0..49df62906 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, @@ -919,6 +920,55 @@ describe('Users tests', () => { 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 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') diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index c3283813f..2fa06a870 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -227,6 +227,7 @@ const env = cleanEnv({ export const rootPath = '/tmp/otomi/values' const clusterSettingsFilePath = 'env/settings/cluster.yaml' +const MIN_USER_PASSWORD_LENGTH = 8 function getTeamSealedSecretsValuesFilePath(teamId: string, sealedSecretsName: string): string { return `env/teams/${teamId}/sealedsecrets/${sealedSecretsName}.yaml` @@ -1272,7 +1273,7 @@ export default class OtomiStack { throw new HttpError(400, error as string) } - const initialPassword = this.generateInitialPassword() + const initialPassword = this.resolveInitialPassword(data.initialPassword) const user: User = { ...data, id: uuidv4(), initialPassword } this.validateUserTeamsExist(user) @@ -1288,6 +1289,16 @@ export default class OtomiStack { return user } + private resolveInitialPassword(suppliedPassword?: string): string { + if (env.AUTH_PROVIDER !== 'dex' || !suppliedPassword) { + return this.generateInitialPassword() + } + if (suppliedPassword.length < MIN_USER_PASSWORD_LENGTH) { + throw new HttpError(400, `Password must be at least ${MIN_USER_PASSWORD_LENGTH} characters.`) + } + return suppliedPassword + } + private generateInitialPassword(): string { return generatePassword({ length: 16, From 82a0991385fe5090037480d6af5078193270ff23 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Wed, 9 Sep 2026 10:59:29 +0200 Subject: [PATCH 04/22] feat: editUser in dex mode to handle email immutability and password validation --- package.json | 5 ++--- src/otomi-stack.test.ts | 27 +++++++++++++++++++++++++++ src/otomi-stack.ts | 19 ++++++++++++++----- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 25ddbfdf7..13bc6bfa8 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,6 @@ }, "main": "dist/src/app.js", "name": "@redkubes/otomi-api", - "name": "@redkubes/otomi-api", "publishConfig": { "private": true, "registry": "https://npm.pkg.github.com" @@ -156,8 +155,8 @@ "release:bump:minor": "standard-version --skip.changelog true --release-as minor", "release:client": "bin/release-client.sh", "start": "node dist/src/app.js", - "test": "npm run build:models && jest", - "test:pattern": "npm run build:models && jest --forceExit", + "test": "npm run build:models && npm run gen:dex-client && jest", + "test:pattern": "npm run build:models && npm run gen:dex-client && jest --forceExit", "types": "tsc --noEmit", "prepare": "husky install", "watch": "npm-watch", diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index 49df62906..53ecf70de 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -1066,6 +1066,33 @@ describe('Users tests', () => { expect(call.newHash.length).toBeGreaterThan(0) }) + 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') diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 2fa06a870..edfc0db74 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1293,10 +1293,14 @@ export default class OtomiStack { if (env.AUTH_PROVIDER !== 'dex' || !suppliedPassword) { return this.generateInitialPassword() } - if (suppliedPassword.length < MIN_USER_PASSWORD_LENGTH) { + 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.`) } - return suppliedPassword } private generateInitialPassword(): string { @@ -1390,15 +1394,20 @@ export default class OtomiStack { private async editDexUser(id: string, data: User): Promise { const { match, user: existingUser } = await this.lookupDexUser(id) - const user: User = { ...existingUser, ...data, id } + // Dex's UpdatePasswordReq has no field to change the record's email — it's the lookup key + // and is documented as immutable — so an email in `data` is not applied. Keeping the + // original here (rather than merging data.email in) stops the response from claiming an + // email change that was never sent to Dex and never took effect. + const user: User = { ...existingUser, ...data, id, email: existingUser.email } 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({ - // The lookup key: Dex's UpdatePasswordReq documents email as immutable, so an email change - // in `data` is not something this call can express - it targets the record as it exists today. email: match.email, newHash, newGroups: deriveDexGroups(user), From 2674c9cf539ab487636a15b6fd868ca5a9959f4b Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Fri, 11 Sep 2026 14:47:01 +0200 Subject: [PATCH 05/22] fix: copilot review comments Signed-off-by: Cas Lubbers --- src/clients/dexClient.test.ts | 9 ++------- src/clients/dexClient.ts | 12 ++---------- src/clients/dexConstants.ts | 1 + src/middleware/jwt.test.ts | 2 +- src/middleware/jwt.ts | 4 ++-- src/otomi-stack.ts | 6 ++---- src/utils/userUtils.ts | 9 ++------- 7 files changed, 12 insertions(+), 31 deletions(-) create mode 100644 src/clients/dexConstants.ts diff --git a/src/clients/dexClient.test.ts b/src/clients/dexClient.test.ts index eee683f93..5955c13c3 100644 --- a/src/clients/dexClient.test.ts +++ b/src/clients/dexClient.test.ts @@ -12,13 +12,8 @@ jest.mock('src/generated/dex/api', () => ({ process.env.DEX_GRPC_ADDRESS = 'localhost:5557' -import { - createDexPassword, - DEX_NO_GROUPS_SENTINEL, - deleteDexPassword, - DexProvisionError, - updateDexPassword, -} from './dexClient' +import { DEX_NO_GROUPS_SENTINEL } from './dexConstants' +import { createDexPassword, deleteDexPassword, DexProvisionError, updateDexPassword } from './dexClient' describe('dexClient', () => { beforeEach(() => { diff --git a/src/clients/dexClient.ts b/src/clients/dexClient.ts index cc670c12e..df0b70154 100644 --- a/src/clients/dexClient.ts +++ b/src/clients/dexClient.ts @@ -2,20 +2,12 @@ import { ChannelCredentials, ServiceError } from '@grpc/grpc-js' import retry from 'async-retry' import { CreatePasswordResp, DeletePasswordResp, DexClient, Password, UpdatePasswordResp } from 'src/generated/dex/api' import { cleanEnv, DEX_GRPC_ADDRESS } from 'src/validators' +import { DEX_NO_GROUPS_SENTINEL } from 'src/clients/dexConstants' export type { Password } const env = cleanEnv({ DEX_GRPC_ADDRESS }) -// Dex's UpdatePassword handler (server/apiserver/passwords.go in the fork) only replaces -// stored groups when the incoming field is non-nil (`if req.NewGroups != nil`). Proto3 repeated -// fields carry no wire presence, so an empty array and an omitted field both unmarshal to nil on -// the server - an empty groups list from us is silently ignored instead of clearing the user's -// groups. Sending this sentinel instead of an empty array gives the field a non-nil, one-element -// value, so a demotion to "no groups" actually reaches Dex. getUser() in src/middleware/jwt.ts -// strips it back out before deriving roles/teams from a token. -export const DEX_NO_GROUPS_SENTINEL = '__no_groups__' - function toDexGroups(groups: string[]): string[] { return groups.length > 0 ? groups : [DEX_NO_GROUPS_SENTINEL] } @@ -37,7 +29,7 @@ function getDexClient(): DexClient { throw new DexProvisionError('DEX_GRPC_ADDRESS must be set when AUTH_PROVIDER=dex') } if (!client) { - // TODO(#3536): createInsecure() is a known temporary gap pending TLS wiring in apl-core. + // Secured by Istio mtls and Authorization policy client = new DexClient(env.DEX_GRPC_ADDRESS, ChannelCredentials.createInsecure()) } return client diff --git a/src/clients/dexConstants.ts b/src/clients/dexConstants.ts new file mode 100644 index 000000000..868f8d2e2 --- /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 7b0523f33..33cfce3f5 100644 --- a/src/middleware/jwt.test.ts +++ b/src/middleware/jwt.test.ts @@ -1,5 +1,5 @@ import { mockDeep } from 'jest-mock-extended' -import { DEX_NO_GROUPS_SENTINEL } from 'src/clients/dexClient' +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' diff --git a/src/middleware/jwt.ts b/src/middleware/jwt.ts index 4398f4c8f..5c70ea8b1 100644 --- a/src/middleware/jwt.ts +++ b/src/middleware/jwt.ts @@ -1,7 +1,7 @@ /* eslint-disable no-param-reassign */ import Debug from 'debug' import { RequestHandler } from 'express' -import { DEX_NO_GROUPS_SENTINEL } from 'src/clients/dexClient' +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' @@ -40,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/otomi-stack.ts b/src/otomi-stack.ts index 615c8380f..7cbd6e6ba 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1519,8 +1519,6 @@ export default class OtomiStack { : this.editGitTeamUsers(data, sessionUser) } - // Dex has no Git counterpart to keep in sync, so there's no two-pass ordering to worry about - // here — each update either lands in Dex or the whole request rejects. private async editDexTeamUsers( data: Pick[], sessionUser: SessionUser, @@ -1540,7 +1538,7 @@ export default class OtomiStack { dexPasswords: Password[], ): Promise> { if (!userData.id) { - throw new NotExistError(`User ${userData.id} not found`) + throw new NotExistError(`User id is required`) } const match = dexPasswords.find((p) => p.userId === userData.id) if (!match) { @@ -1563,7 +1561,7 @@ export default class OtomiStack { 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 this.requireUserSecretData(userData.id) const existingUser = userSecretDataToUser(existingData) diff --git a/src/utils/userUtils.ts b/src/utils/userUtils.ts index d46059b26..487e2766e 100644 --- a/src/utils/userUtils.ts +++ b/src/utils/userUtils.ts @@ -1,5 +1,6 @@ import axios from 'axios' -import { DEX_NO_GROUPS_SENTINEL, Password } from 'src/clients/dexClient' +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' @@ -86,8 +87,6 @@ export interface GroupSource { teams?: string[] } -// Mirrors the naming convention read back out of Keycloak-issued tokens -// in src/middleware/jwt.ts's getUser() — platform-admin / team-admin / team-. export function deriveDexGroups(user: GroupSource): string[] { const groups: string[] = [] if (user.isPlatformAdmin) groups.push('platform-admin') @@ -96,10 +95,6 @@ export function deriveDexGroups(user: GroupSource): string[] { return groups } -// Inverse of deriveDexGroups: when AUTH_PROVIDER=dex, Dex's own password store is the only -// place a user record lives (see otomi-stack.ts createUser/getUser/editUser/editTeamUsers/ -// deleteUser) — this reconstructs a User-shaped object from the groups Dex hands back over -// ListPasswords. Dex carries no firstName/lastName/initialPassword, so those come back undefined. export function dexPasswordToUser(password: Password): User { const groups = password.groups.filter((group) => group !== DEX_NO_GROUPS_SENTINEL) return { From 8247c6b28335c8a26c8b78d0fc2be1c0403138ab Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Wed, 16 Sep 2026 15:57:21 +0200 Subject: [PATCH 06/22] fix: review comments Signed-off-by: Cas Lubbers --- src/clients/dexClient.test.ts | 9 +++++---- src/clients/dexClient.ts | 5 +++-- src/openapi/user.yaml | 4 +++- src/otomi-stack.test.ts | 16 ++++++++++++++++ src/otomi-stack.ts | 6 ++++++ src/utils/passwordUtils.ts | 5 +++++ 6 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/clients/dexClient.test.ts b/src/clients/dexClient.test.ts index 5955c13c3..98bf5a8f1 100644 --- a/src/clients/dexClient.test.ts +++ b/src/clients/dexClient.test.ts @@ -13,6 +13,7 @@ jest.mock('src/generated/dex/api', () => ({ process.env.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', () => { @@ -64,12 +65,12 @@ describe('dexClient', () => { ).rejects.toBeInstanceOf(DexProvisionError) }) - it('createDexPassword rejects with DexProvisionError when Dex reports alreadyExists', async () => { + 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(DexProvisionError) + ).rejects.toBeInstanceOf(AlreadyExists) }) it('updateDexPassword only sets provided fields', async () => { @@ -102,10 +103,10 @@ describe('dexClient', () => { expect(mockUpdatePassword).toHaveBeenCalledWith(expect.objectContaining({ newGroups: [] }), expect.any(Function)) }) - it('updateDexPassword rejects with DexProvisionError when the record is not found', async () => { + 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(DexProvisionError) + await expect(updateDexPassword({ email: 'ghost@b.com' })).rejects.toBeInstanceOf(NotExistError) }) it('deleteDexPassword resolves even when Dex reports not found (idempotent)', async () => { diff --git a/src/clients/dexClient.ts b/src/clients/dexClient.ts index df0b70154..7ea5ab8db 100644 --- a/src/clients/dexClient.ts +++ b/src/clients/dexClient.ts @@ -3,6 +3,7 @@ import retry from 'async-retry' import { CreatePasswordResp, DeletePasswordResp, DexClient, Password, UpdatePasswordResp } from 'src/generated/dex/api' 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 } @@ -68,7 +69,7 @@ export async function createDexPassword(input: CreateDexPasswordInput): Promise< return } if (resp?.alreadyExists) { - reject(new DexProvisionError(`Dex already has a password record for ${input.email}`)) + reject(new AlreadyExists(`Dex already has a password record for ${input.email}`)) return } resolve() @@ -103,7 +104,7 @@ export async function updateDexPassword(input: UpdateDexPasswordInput): Promise< return } if (resp?.notFound) { - reject(new DexProvisionError(`Dex has no password record for ${input.email}`)) + reject(new NotExistError(`Dex has no password record for ${input.email}`)) return } resolve() diff --git a/src/openapi/user.yaml b/src/openapi/user.yaml index 1c6ba39b9..159264b5e 100644 --- a/src/openapi/user.yaml +++ b/src/openapi/user.yaml @@ -115,7 +115,9 @@ User: uniqueItems: true initialPassword: type: string - description: The initial password of the user. With Dex as issuer, an admin may set this on create; otherwise one is generated. + minLength: 8 + maxLength: 72 + description: The initial password of the user. With Dex as issuer, an admin may set this on create; otherwise one is generated. Must be between 8 and 72 bytes UTF-8 encoded (bcrypt only consumes the first 72 bytes). required: - email type: object diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index 53ecf70de..422a4ef2b 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -952,6 +952,22 @@ describe('Users tests', () => { 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') diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 7cbd6e6ba..8c182b767 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -228,6 +228,7 @@ 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` @@ -1301,6 +1302,11 @@ export default class OtomiStack { if (password.length < MIN_USER_PASSWORD_LENGTH) { throw new HttpError(400, `Password must be at least ${MIN_USER_PASSWORD_LENGTH} characters.`) } + // bcrypt only consumes the first 72 UTF-8 bytes; anything beyond that is silently ignored, + // so bytes past this point would authenticate identically regardless of their value. + 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 { diff --git a/src/utils/passwordUtils.ts b/src/utils/passwordUtils.ts index 0575c3753..edfe365af 100644 --- a/src/utils/passwordUtils.ts +++ b/src/utils/passwordUtils.ts @@ -2,6 +2,11 @@ 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) } From 500ad55f192df6aae5bd3c9d646f60219369b0d7 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Wed, 16 Sep 2026 16:03:19 +0200 Subject: [PATCH 07/22] fix: review comments Signed-off-by: Cas Lubbers --- src/openapi/api.yaml | 3 +++ src/otomi-stack.test.ts | 18 ++++++++++++++++++ src/otomi-stack.ts | 6 ++++++ 3 files changed, 27 insertions(+) diff --git a/src/openapi/api.yaml b/src/openapi/api.yaml index 187a52568..08c7877b4 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/otomi-stack.test.ts b/src/otomi-stack.test.ts index 422a4ef2b..5a778d766 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -805,6 +805,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', @@ -1124,6 +1130,18 @@ describe('Users tests', () => { 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 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'] }), diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 8c182b767..e5f6a3944 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1546,6 +1546,9 @@ export default class OtomiStack { 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 ${userData.id} not found`) @@ -1569,6 +1572,9 @@ export default class OtomiStack { if (!userData.id) { throw new NotExistError(`User id is required`) } + if (!userData.teams) { + throw new BadRequestError(`User teams is required`) + } const existingData = await this.requireUserSecretData(userData.id) const existingUser = userSecretDataToUser(existingData) this.assertCanUpdateUserTeams(sessionUser, existingUser, userData.teams as string[]) From 63a2e36d6c20e454d3dd69e08e3628db4113c68e Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Wed, 16 Sep 2026 17:01:06 +0200 Subject: [PATCH 08/22] fix: review comments Signed-off-by: Cas Lubbers --- src/api.authz.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api.authz.test.ts b/src/api.authz.test.ts index 3c94e93d3..20421ef5b 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) }) From b1aa241b74e972730a3dd0ec29852c1c48aab6a1 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Thu, 17 Sep 2026 15:13:20 +0200 Subject: [PATCH 09/22] fix: review comments Signed-off-by: Cas Lubbers --- src/clients/dexClient.ts | 46 +++++++++++++++++++++++++++++++++++++++- src/otomi-stack.test.ts | 39 ++++++++++++++++++++++++++++++++++ src/otomi-stack.ts | 29 +++++++++++++++++-------- 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/src/clients/dexClient.ts b/src/clients/dexClient.ts index 7ea5ab8db..9b43f2a67 100644 --- a/src/clients/dexClient.ts +++ b/src/clients/dexClient.ts @@ -1,6 +1,14 @@ import { ChannelCredentials, ServiceError } from '@grpc/grpc-js' import retry from 'async-retry' -import { CreatePasswordResp, DeletePasswordResp, DexClient, Password, UpdatePasswordResp } from 'src/generated/dex/api' +import { + CreatePasswordResp, + DeletePasswordResp, + DeleteUserIdentityResp, + DexClient, + Password, + UpdatePasswordResp, + UserIdentity, +} from 'src/generated/dex/api' import { cleanEnv, DEX_GRPC_ADDRESS } from 'src/validators' import { DEX_NO_GROUPS_SENTINEL } from 'src/clients/dexConstants' import { AlreadyExists, NotExistError } from 'src/error' @@ -148,3 +156,39 @@ export async function deleteDexPassword(email: string): Promise { }), ) } + +export async function listUserIdentitiesByUserId(userId: string): Promise { + const dex = getDexClient() + const identities = await callWithRetry( + () => + 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 src/proto/dex/api.proto). +export async function deleteDexUserIdentity(userId: string, connectorId: string): Promise { + const dex = getDexClient() + await callWithRetry( + () => + 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/otomi-stack.test.ts b/src/otomi-stack.test.ts index 5a778d766..b7f68c1ed 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -66,12 +66,16 @@ 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 {}, })) @@ -1088,6 +1092,29 @@ describe('Users tests', () => { 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: [] }), @@ -1176,6 +1203,18 @@ describe('Users tests', () => { 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 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')) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index e5f6a3944..6aa64f5ac 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -154,7 +154,9 @@ import CloudTty from './tty' import { createDexPassword, deleteDexPassword, + deleteDexUserIdentity, listDexPasswords, + listUserIdentitiesByUserId, Password, updateDexPassword, } from './clients/dexClient' @@ -1302,8 +1304,7 @@ export default class OtomiStack { if (password.length < MIN_USER_PASSWORD_LENGTH) { throw new HttpError(400, `Password must be at least ${MIN_USER_PASSWORD_LENGTH} characters.`) } - // bcrypt only consumes the first 72 UTF-8 bytes; anything beyond that is silently ignored, - // so bytes past this point would authenticate identically regardless of their value. + // 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.`) } @@ -1400,11 +1401,16 @@ export default class OtomiStack { private async editDexUser(id: string, data: User): Promise { const { match, user: existingUser } = await this.lookupDexUser(id) - // Dex's UpdatePasswordReq has no field to change the record's email — it's the lookup key - // and is documented as immutable — so an email in `data` is not applied. Keeping the - // original here (rather than merging data.email in) stops the response from claiming an - // email change that was never sent to Dex and never took effect. - const user: User = { ...existingUser, ...data, id, email: existingUser.email } + // email is immutable in Dex; groups are merged individually so an omitted field is kept, not wiped. + const user: User = { + ...existingUser, + ...data, + id, + 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 @@ -1456,7 +1462,12 @@ export default class OtomiStack { if (match.email === env.DEFAULT_PLATFORM_ADMIN_EMAIL) { throw new ForbiddenError('Cannot delete the default platform admin user') } - // Dex is the only place a dex-provisioned user's record lives — no SealedSecret to remove. + // Purge identities first: it cascades to sessions, refresh/offline tokens, and the password + // record. A user who never logged in has no identity, so deleteDexPassword below still runs. + const identities = await listUserIdentitiesByUserId(match.userId) + for (const identity of identities) { + await deleteDexUserIdentity(identity.userId, identity.connectorId) + } await deleteDexPassword(match.email) } @@ -1551,7 +1562,7 @@ export default class OtomiStack { } const match = dexPasswords.find((p) => p.userId === userData.id) if (!match) { - throw new NotExistError(`User ${userData.id} not found`) + throw new NotExistError(`User not found`) } const existingUser = dexPasswordToUser(match) this.assertCanUpdateUserTeams(sessionUser, existingUser, userData.teams as string[]) From 2a7c40cc11522f99cc12c2c3f79334900d800f4e Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Thu, 17 Sep 2026 17:03:17 +0200 Subject: [PATCH 10/22] fix: add issuer to otomi Signed-off-by: Cas Lubbers --- src/openapi/settings.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/openapi/settings.yaml b/src/openapi/settings.yaml index 99ccff0ab..43b83d351 100644 --- a/src/openapi/settings.yaml +++ b/src/openapi/settings.yaml @@ -278,6 +278,17 @@ Settings: platformAdmin: [read-any, update-any] teamAdmin: [] teamMember: [] + issuer: + 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 From b5e8fdbb96e41443c6272299134921eceadcd53d Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Wed, 23 Sep 2026 10:57:01 +0200 Subject: [PATCH 11/22] fix: remove retries in dex client Signed-off-by: Cas Lubbers --- src/clients/dexClient.ts | 193 +++++++++++++++++---------------------- 1 file changed, 85 insertions(+), 108 deletions(-) diff --git a/src/clients/dexClient.ts b/src/clients/dexClient.ts index 9b43f2a67..f5adadd98 100644 --- a/src/clients/dexClient.ts +++ b/src/clients/dexClient.ts @@ -1,5 +1,4 @@ import { ChannelCredentials, ServiceError } from '@grpc/grpc-js' -import retry from 'async-retry' import { CreatePasswordResp, DeletePasswordResp, @@ -44,10 +43,6 @@ function getDexClient(): DexClient { return client } -function callWithRetry(fn: () => Promise): Promise { - return retry(fn, { retries: 3, minTimeout: 200 }) -} - export interface CreateDexPasswordInput { id: string email: string @@ -58,33 +53,30 @@ export interface CreateDexPasswordInput { export async function createDexPassword(input: CreateDexPasswordInput): Promise { const dex = getDexClient() - await callWithRetry( - () => - 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() - }, - ) - }), - ) + 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 { @@ -96,81 +88,69 @@ export interface UpdateDexPasswordInput { export async function updateDexPassword(input: UpdateDexPasswordInput): Promise { const dex = getDexClient() - await callWithRetry( - () => - 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() - }, - ) - }), - ) + 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 callWithRetry( - () => - 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 ?? []) - }) - }), - ) + 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 callWithRetry( - () => - 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() - }) - }), - ) + 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 callWithRetry( - () => - 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 ?? []) - }) - }), - ) + 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) } @@ -178,17 +158,14 @@ export async function listUserIdentitiesByUserId(userId: string): Promise { const dex = getDexClient() - await callWithRetry( - () => - 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() - }) - }), - ) + 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() + }) + }) } From a01d290a69e87c7b23f737374efb5531eb1b87fe Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Thu, 24 Sep 2026 13:22:10 +0200 Subject: [PATCH 12/22] feat: add groupsClaimMapper Signed-off-by: Cas Lubbers --- src/openapi/settings.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/openapi/settings.yaml b/src/openapi/settings.yaml index 43b83d351..bf6ba50f7 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: From 4f7a3d1fc17cb2aa937d95bc84b829b092f4ab68 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Tue, 29 Sep 2026 11:18:56 +0200 Subject: [PATCH 13/22] fix: review comments Signed-off-by: Cas Lubbers --- src/otomi-stack.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++ src/otomi-stack.ts | 15 ++++++++------ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index 05209887a..0c6a18d81 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -1172,6 +1172,8 @@ describe('Users tests', () => { 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) @@ -1196,6 +1198,19 @@ describe('Users tests', () => { 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'] }), @@ -1203,6 +1218,8 @@ describe('Users tests', () => { ]) 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( @@ -1242,6 +1259,32 @@ describe('Users tests', () => { 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')) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 3459cbea4..2f30f80bc 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1459,17 +1459,19 @@ export default class OtomiStack { } private async deleteDexUser(id: string): Promise { - const { match } = await this.lookupDexUser(id) - if (match.email === env.DEFAULT_PLATFORM_ADMIN_EMAIL) { + // 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') } - // Purge identities first: it cascades to sessions, refresh/offline tokens, and the password - // record. A user who never logged in has no identity, so deleteDexPassword below still runs. - const identities = await listUserIdentitiesByUserId(match.userId) for (const identity of identities) { await deleteDexUserIdentity(identity.userId, identity.connectorId) } - await deleteDexPassword(match.email) + await deleteDexPassword(email) } private async deleteGitUser(id: string): Promise { @@ -1565,6 +1567,7 @@ export default class OtomiStack { 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 || [] } } From 5cc9f8de6026f4cde19aca2a8aa568ed2e6fcaf9 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Tue, 29 Sep 2026 14:58:13 +0200 Subject: [PATCH 14/22] fix: use dex-client-grpc package Signed-off-by: Cas Lubbers --- .gitignore | 1 - Dockerfile | 3 +- eslint.config.mjs | 1 - package-lock.json | 246 +------------- package.json | 13 +- src/clients/dexClient.test.ts | 12 +- src/clients/dexClient.ts | 4 +- src/proto/dex/api.proto | 604 ---------------------------------- src/utils/userUtils.test.ts | 2 +- 9 files changed, 31 insertions(+), 855 deletions(-) delete mode 100644 src/proto/dex/api.proto diff --git a/.gitignore b/.gitignore index 052862281..87164783e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,6 @@ kms.json* /vendors/client/ /src/generated-* -/src/generated/ /src/values-schema.yaml secrets.*.yaml.dec diff --git a/Dockerfile b/Dockerfile index edebae9b5..671922a02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,9 @@ WORKDIR /app # Install dependencies before copying the full source code to take advantage of Docker layer caching COPY package*.json ./ -# Needed for postinstall (build:models, gen:dex-client) during npm ci +# Needed for postinstall (build:models) during npm ci COPY src/build-spec.ts ./src/build-spec.ts COPY src/openapi ./src/openapi -COPY src/proto ./src/proto RUN npm ci COPY . .* ./ diff --git a/eslint.config.mjs b/eslint.config.mjs index fb323bf7d..65bc56f6f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,7 +17,6 @@ export default defineConfig([ 'node_modules/*', 'vendors/*', 'src/generated-schema.ts', - 'src/generated/*', ]), { files: ['**/*.ts'], diff --git a/package-lock.json b/package-lock.json index 35c1efe26..01b406e56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,11 @@ "license": "ISC", "dependencies": { "@apidevtools/json-schema-ref-parser": "16.0.2", - "@bufbuild/protobuf": "^2.14.1", "@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", @@ -85,7 +85,6 @@ "git-branch-is": "5.0.0", "git-cz": "4.9.0", "globals": "17.12.0", - "grpc-tools": "^1.13.1", "husky": "9.1.7", "jest": "30.5.2", "jest-mock-extended": "4.0.1", @@ -105,7 +104,6 @@ "swagger-node-codegen": "1.6.3", "ts-jest": "^29.4.12", "ts-node": "^10.9.2", - "ts-proto": "^2.12.1", "tsc-alias": "1.9.5", "tsconfig-paths": "4.2.0", "tsx": "4.23.13", @@ -4218,19 +4216,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -4904,6 +4889,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", @@ -4933,78 +4929,6 @@ "node": ">= 10" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.3.tgz", - "integrity": "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "consola": "^3.2.3", - "detect-libc": "^2.0.0", - "https-proxy-agent": "^7.0.5", - "node-fetch": "^2.6.7", - "nopt": "^8.0.0", - "semver": "^7.5.3", - "tar": "^7.4.0" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/abbrev": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", - "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", - "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^3.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -9004,19 +8928,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/case-anything": { - "version": "2.1.13", - "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz", - "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.13" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -9134,16 +9045,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/ci-info": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", @@ -9516,16 +9417,6 @@ "proto-list": "~1.2.1" } }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -10913,29 +10804,6 @@ "node": ">=4" } }, - "node_modules/dprint-node": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/dprint-node/-/dprint-node-1.0.8.tgz", - "integrity": "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^1.0.3" - } - }, - "node_modules/dprint-node/node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -13551,20 +13419,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/grpc-tools": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/grpc-tools/-/grpc-tools-1.13.1.tgz", - "integrity": "sha512-0sttMUxThNIkCTJq5qI0xXMz5zWqV2u3yG1kR3Sj9OokGIoyRBFjoInK9NyW7x5fH7knj48Roh1gq5xbl0VoDQ==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@mapbox/node-pre-gyp": "^2.0.0" - }, - "bin": { - "grpc_tools_node_protoc": "bin/protoc.js", - "grpc_tools_node_protoc_plugin": "bin/protoc_plugin.js" - } - }, "node_modules/handlebars": { "version": "4.7.9", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", @@ -16827,19 +16681,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -23976,23 +23817,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tar": { - "version": "7.5.22", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", - "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/tar-fs": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.10.tgz", @@ -24018,16 +23842,6 @@ "streamx": "^2.15.0" } }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/temp-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", @@ -24585,42 +24399,6 @@ } } }, - "node_modules/ts-poet": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz", - "integrity": "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dprint-node": "^1.0.8" - } - }, - "node_modules/ts-proto": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.12.1.tgz", - "integrity": "sha512-IEFvmib22yVlXbagL/UXcfliCPilgqXs3J0/ajDhUslfezMRhhhZvwgc63W0ZrKxCj+8jMHxvrN6G13A5UGFVg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@bufbuild/protobuf": "^2.10.2", - "case-anything": "^2.1.13", - "ts-poet": "^6.12.0", - "ts-proto-descriptors": "2.1.0" - }, - "bin": { - "protoc-gen-ts_proto": "protoc-gen-ts_proto" - } - }, - "node_modules/ts-proto-descriptors": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.1.0.tgz", - "integrity": "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@bufbuild/protobuf": "^2.0.0" - } - }, "node_modules/tsc-alias": { "version": "1.9.5", "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.9.5.tgz", diff --git a/package.json b/package.json index b6db14a2d..e1ed21f0d 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ }, "dependencies": { "@apidevtools/json-schema-ref-parser": "16.0.2", - "@bufbuild/protobuf": "^2.14.1", "@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", @@ -85,7 +85,6 @@ "git-branch-is": "5.0.0", "git-cz": "4.9.0", "globals": "17.12.0", - "grpc-tools": "^1.13.1", "husky": "9.1.7", "jest": "30.5.2", "jest-mock-extended": "4.0.1", @@ -106,7 +105,6 @@ "ts-jest": "^29.4.12", "ts-node": "^10.9.2", "tsc-alias": "1.9.5", - "ts-proto": "^2.12.1", "tsconfig-paths": "4.2.0", "tsx": "4.23.13", "typescript": "5.9.3", @@ -134,10 +132,9 @@ "url": "git+https://github.com/redkubes/otomi-api.git" }, "scripts": { - "build": "npm run clean && npm run build:models && npm run gen:dex-client && 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", + "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", "build:models": "npm run build:spec && openapi-typescript src/generated-schema.json -o src/generated-schema.ts --default-non-nullable false", "build:spec": "tsx src/build-spec.ts", - "gen:dex-client": "mkdir -p src/generated/dex && grpc_tools_node_protoc --plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=src/generated/dex --ts_proto_opt=outputServices=grpc-js,esModuleInterop=true,env=node,outputJsonMethods=false,outputClientImpl=true -I src/proto/dex src/proto/dex/api.proto", "clean": "rm -rf dist >/dev/null", "cz": "git-cz", "cz:retry": "git-cz --retry", @@ -148,15 +145,15 @@ "lint:ts": "eslint --ext ts .", "lint:fix": "eslint --ext ts --fix .", "lint-staged": "lint-staged", - "postinstall": "npm run build:models && npm run gen:dex-client", + "postinstall": "npm run build:models", "pre-release:client": "npm version prerelease --preid rc --no-commit-hooks --no-git-tag-version && bin/release-client.sh", "release": "standard-version", "schema:sync": "APL_CORE_PATH=${APL_CORE_PATH:-../apl-core} && cp \"$APL_CORE_PATH/values-schema.yaml\" src/values-schema.yaml && echo \"Schema synced from $APL_CORE_PATH\"", "release:bump:minor": "standard-version --skip.changelog true --release-as minor", "release:client": "bin/release-client.sh", "start": "node dist/src/app.js", - "test": "npm run build:models && npm run gen:dex-client && jest", - "test:pattern": "npm run build:models && npm run gen:dex-client && jest --forceExit", + "test": "npm run build:models && jest", + "test:pattern": "npm run build:models && jest --forceExit", "types": "tsc --noEmit", "prepare": "husky install", "watch": "npm-watch", diff --git a/src/clients/dexClient.test.ts b/src/clients/dexClient.test.ts index 98bf5a8f1..bb2903fbf 100644 --- a/src/clients/dexClient.test.ts +++ b/src/clients/dexClient.test.ts @@ -2,7 +2,7 @@ const mockCreatePassword = jest.fn() const mockUpdatePassword = jest.fn() const mockDeletePassword = jest.fn() -jest.mock('src/generated/dex/api', () => ({ +jest.mock('@linode/dex-client-grpc', () => ({ DexClient: jest.fn().mockImplementation(() => ({ createPassword: mockCreatePassword, updatePassword: mockUpdatePassword, @@ -10,7 +10,15 @@ jest.mock('src/generated/dex/api', () => ({ })), })) -process.env.DEX_GRPC_ADDRESS = 'localhost:5557' +// 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' diff --git a/src/clients/dexClient.ts b/src/clients/dexClient.ts index f5adadd98..128616051 100644 --- a/src/clients/dexClient.ts +++ b/src/clients/dexClient.ts @@ -7,7 +7,7 @@ import { Password, UpdatePasswordResp, UserIdentity, -} from 'src/generated/dex/api' +} 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' @@ -155,7 +155,7 @@ export async function listUserIdentitiesByUserId(userId: string): Promise { const dex = getDexClient() await new Promise((resolve, reject) => { diff --git a/src/proto/dex/api.proto b/src/proto/dex/api.proto deleted file mode 100644 index bde1d5c08..000000000 --- a/src/proto/dex/api.proto +++ /dev/null @@ -1,604 +0,0 @@ -syntax = "proto3"; - -package api; - -option java_package = "com.coreos.dex.api"; -option go_package = "github.com/dexidp/dex/api/v2;api"; - -// Client represents an OAuth2 client. -message Client { - string id = 1; - string secret = 2; - repeated string redirect_uris = 3; - repeated string trusted_peers = 4; - bool public = 5; - string name = 6; - string logo_url = 7; - repeated string allowed_connectors = 8; - repeated string sso_shared_with = 9; - // Where dex POSTs a logout token when a session this client took part in - // ends, per OIDC Back-Channel Logout 1.0. Empty means the client is not - // notified. - string backchannel_logout_uri = 10; - // Where the browser may be sent after an RP-initiated logout. A - // post_logout_redirect_uri that is not listed here is refused. - repeated string post_logout_redirect_uris = 11; - // Whether this client's refresh tokens outlive the browser session that - // issued them: "standalone" (the default) or "session". - string refresh_token_lifetime = 12; -} - -// ClientInfo represents an OAuth2 client without sensitive information. -message ClientInfo { - string id = 1; - repeated string redirect_uris = 2; - repeated string trusted_peers = 3; - bool public = 4; - string name = 5; - string logo_url = 6; - repeated string allowed_connectors = 7; - repeated string sso_shared_with = 8; - string backchannel_logout_uri = 9; - repeated string post_logout_redirect_uris = 10; - string refresh_token_lifetime = 11; -} - -// GetClientReq is a request to retrieve client details. -message GetClientReq { - // The ID of the client. - string id = 1; -} - -// GetClientResp returns the client details. -message GetClientResp { - Client client = 1; -} - -// CreateClientReq is a request to make a client. -message CreateClientReq { - Client client = 1; -} - -// CreateClientResp returns the response from creating a client. -message CreateClientResp { - bool already_exists = 1; - Client client = 2; -} - -// DeleteClientReq is a request to delete a client. -message DeleteClientReq { - // The ID of the client. - string id = 1; -} - -// DeleteClientResp determines if the client is deleted successfully. -message DeleteClientResp { - bool not_found = 1; -} - -// UpdateClientReq is a request to update an existing client. -message UpdateClientReq { - string id = 1; - repeated string redirect_uris = 2; - repeated string trusted_peers = 3; - string name = 4; - string logo_url = 5; - repeated string allowed_connectors = 6; - repeated string sso_shared_with = 7; - // Optional so that an empty value clears the URI. Without explicit presence - // a client could be given a back-channel endpoint but never relieved of one, - // leaving dex posting logout tokens at something that no longer exists. - optional string backchannel_logout_uri = 8; - repeated string post_logout_redirect_uris = 9; - // Optional for the same reason as backchannel_logout_uri: an empty value has - // to be tellable apart from "leave it alone" to put a client back on the - // default lifetime. - optional string refresh_token_lifetime = 10; -} - -// UpdateClientResp returns the response from updating a client. -message UpdateClientResp { - bool not_found = 1; -} - -// ListClientReq is a request to enumerate clients. -message ListClientReq {} - -// ListClientResp returns a list of clients. -message ListClientResp { - repeated ClientInfo clients = 1; -} - -// TODO(ericchiang): expand this. - -// Password is an email for password mapping managed by the storage. -message Password { - string email = 1; - - // Currently we do not accept plain text passwords. Could be an option in the future. - bytes hash = 2; - string username = 3; - string user_id = 4; - repeated string groups = 5; -} - -// CreatePasswordReq is a request to make a password. -message CreatePasswordReq { - Password password = 1; -} - -// CreatePasswordResp returns the response from creating a password. -message CreatePasswordResp { - bool already_exists = 1; -} - -// UpdatePasswordReq is a request to modify an existing password. -message UpdatePasswordReq { - // The email used to lookup the password. This field cannot be modified - string email = 1; - bytes new_hash = 2; - string new_username = 3; - repeated string new_groups = 4; -} - -// UpdatePasswordResp returns the response from modifying an existing password. -message UpdatePasswordResp { - bool not_found = 1; -} - -// DeletePasswordReq is a request to delete a password. -message DeletePasswordReq { - string email = 1; -} - -// DeletePasswordResp returns the response from deleting a password. -message DeletePasswordResp { - bool not_found = 1; -} - -// ListPasswordReq is a request to enumerate passwords. -message ListPasswordReq {} - -// ListPasswordResp returns a list of passwords. -message ListPasswordResp { - repeated Password passwords = 1; -} - -// Connector is a strategy used by Dex for authenticating a user against another identity provider -message Connector { - string id = 1; - string type = 2; - string name = 3; - bytes config = 4; - repeated string grant_types = 5; -} - -// CreateConnectorReq is a request to make a connector. -message CreateConnectorReq { - Connector connector = 1; -} - -// CreateConnectorResp returns the response from creating a connector. -message CreateConnectorResp { - bool already_exists = 1; -} - -// GrantTypes wraps a list of grant types to distinguish between -// "not specified" (no update) and "empty list" (unrestricted). -message GrantTypes { - repeated string grant_types = 1; -} - -// UpdateConnectorReq is a request to modify an existing connector. -message UpdateConnectorReq { - // The id used to lookup the connector. This field cannot be modified - string id = 1; - string new_type = 2; - string new_name = 3; - bytes new_config = 4; - // If set, updates the connector's allowed grant types. - // An empty grant_types list means unrestricted (all grant types allowed). - // If not set (null), grant types are not modified. - GrantTypes new_grant_types = 5; -} - -// UpdateConnectorResp returns the response from modifying an existing connector. -message UpdateConnectorResp { - bool not_found = 1; -} - -// DeleteConnectorReq is a request to delete a connector. -message DeleteConnectorReq { - string id = 1; -} - -// DeleteConnectorResp returns the response from deleting a connector. -message DeleteConnectorResp { - bool not_found = 1; -} - -// ListConnectorReq is a request to enumerate connectors. -message ListConnectorReq {} - -// ListConnectorResp returns a list of connectors. -message ListConnectorResp { - repeated Connector connectors = 1; -} - -// VersionReq is a request to fetch version info. -message VersionReq {} - -// VersionResp holds the version info of components. -message VersionResp { - // Semantic version of the server. - string server = 1; - // Numeric version of the API. It increases every time a new call is added to the API. - // Clients should use this info to determine if the server supports specific features. - int32 api = 2; -} - -// DiscoveryReq is a request to fetch discover information. -message DiscoveryReq {} - -//DiscoverResp holds the version oidc disovery info. -message DiscoveryResp { - string issuer = 1; - string authorization_endpoint = 2; - string token_endpoint = 3; - string jwks_uri = 4; - string userinfo_endpoint = 5; - string device_authorization_endpoint = 6; - string introspection_endpoint = 7; - repeated string grant_types_supported = 8; - repeated string response_types_supported = 9; - repeated string subject_types_supported = 10; - repeated string id_token_signing_alg_values_supported = 11; - repeated string code_challenge_methods_supported = 12; - repeated string scopes_supported = 13; - repeated string token_endpoint_auth_methods_supported = 14; - repeated string claims_supported = 15; -} - -// RefreshTokenRef contains the metadata for a refresh token that is managed by the storage. -message RefreshTokenRef { - // ID of the refresh token. - string id = 1; - string client_id = 2; - int64 created_at = 5; - int64 last_used = 6; -} - -// ListRefreshReq is a request to enumerate the refresh tokens of a user. -message ListRefreshReq { - // The "sub" claim returned in the ID Token. - string user_id = 1; -} - -// ListRefreshResp returns a list of refresh tokens for a user. -message ListRefreshResp { - repeated RefreshTokenRef refresh_tokens = 1; -} - -// RevokeRefreshReq is a request to revoke the refresh token of the user-client pair. -message RevokeRefreshReq { - // The "sub" claim returned in the ID Token. - string user_id = 1; - string client_id = 2; -} - -// RevokeRefreshResp determines if the refresh token is revoked successfully. -message RevokeRefreshResp { - // Set to true is refresh token was not found and token could not be revoked. - bool not_found = 1; -} - -message VerifyPasswordReq { - string email = 1; - string password = 2; -} - -message VerifyPasswordResp { - bool verified = 1; - bool not_found = 2; -} - -// ClientAuthState represents authentication state for a specific client within a session. -// The user_id and connector_id are on the parent AuthSession message. -message ClientAuthState { - string client_id = 1; - int64 authenticated_at = 2; - int64 last_activity = 3; - int64 last_token_issued_at = 4; - // Whether this client was reached through another client's SSO sharing rather - // than by authenticating directly. - bool via_sso = 5; -} - -// AuthSession represents a user's authentication session. -message AuthSession { - // Random identifier of the session, published to clients as the "sid" claim. One - // signed-in browser is one session, so a user has as many as they have devices. - string id = 10; - string user_id = 1; - string connector_id = 2; - repeated ClientAuthState client_states = 3; - int64 created_at = 4; - int64 last_activity = 5; - string ip_address = 6; - string user_agent = 7; - int64 absolute_expiry = 8; - int64 idle_expiry = 9; -} - -// GetAuthSessionReq is a request to retrieve an auth session. -message GetAuthSessionReq { - string id = 1; -} - -// GetAuthSessionResp returns the auth session details. -message GetAuthSessionResp { - AuthSession session = 1; -} - -// ListAuthSessionsReq is a request to list auth sessions. -message ListAuthSessionsReq { - // Optional filter: if set, only sessions for this user are returned. - string user_id = 1; - // Optional filter: if set, only sessions from this connector are returned. - string connector_id = 2; -} - -// ListAuthSessionsResp returns a list of auth sessions. -message ListAuthSessionsResp { - repeated AuthSession sessions = 1; -} - -// DeleteAuthSessionReq is a request to delete an auth session. -// Deleting a session also revokes all associated refresh tokens (consistent with logout behavior). -message DeleteAuthSessionReq { - string id = 1; -} - -// DeleteAuthSessionResp returns the result of deleting an auth session. -message DeleteAuthSessionResp { - bool not_found = 1; -} - -// TerminateSessionsByConnectorReq is a request to terminate all sessions for a connector. -// Use when connector configuration changes or is removed. Also revokes associated refresh tokens. -message TerminateSessionsByConnectorReq { - string connector_id = 1; -} - -// TerminateSessionsByConnectorResp returns the count of terminated sessions. -message TerminateSessionsByConnectorResp { - int64 sessions_terminated = 1; -} - -// TerminateSessionsByUserReq is a request to terminate all sessions for a user. -// Use for account compromise scenarios. Also revokes associated refresh tokens. -message TerminateSessionsByUserReq { - string user_id = 1; -} - -// TerminateSessionsByUserResp returns the count of terminated sessions. -message TerminateSessionsByUserResp { - int64 sessions_terminated = 1; -} - -// ConsentEntry represents approved scopes for a single client. -message ConsentEntry { - string client_id = 1; - repeated string scopes = 2; -} - -// MFASecret represents metadata of an enrolled MFA authenticator. -// The actual secret value is never exposed through the admin API. -message MFASecret { - string authenticator_id = 1; - string type = 2; - bool confirmed = 3; - int64 created_at = 4; -} - -// WebAuthnCredential represents metadata of a registered WebAuthn credential. -// The public key is never exposed through the admin API. -message WebAuthnCredential { - bytes credential_id = 1; - string attestation_type = 2; - bytes aaguid = 3; - uint32 sign_count = 4; - bool clone_warning = 5; - repeated string transport = 6; - bool backup_eligible = 7; - bool backup_state = 8; - string display_name = 9; - int64 created_at = 10; -} - -// MFADeviceInfo groups MFA secret and WebAuthn credentials for one authenticator. -message MFADeviceInfo { - string authenticator_id = 1; - MFASecret mfa_secret = 2; - repeated WebAuthnCredential webauthn_credentials = 3; -} - -// UserIdentity represents persistent per-user identity data. -message UserIdentity { - string user_id = 1; - string connector_id = 2; - string email = 3; - bool email_verified = 4; - string username = 5; - repeated string groups = 6; - repeated ConsentEntry consents = 7; - repeated MFADeviceInfo mfa_devices = 8; - int64 created_at = 9; - int64 last_login = 10; - int64 blocked_until = 11; -} - -// GetUserIdentityReq is a request to retrieve a user identity. -message GetUserIdentityReq { - string user_id = 1; - string connector_id = 2; -} - -// GetUserIdentityResp returns the user identity details. -message GetUserIdentityResp { - UserIdentity identity = 1; -} - -// ListUserIdentitiesReq is a request to list user identities. -message ListUserIdentitiesReq {} - -// ListUserIdentitiesResp returns a list of user identities. -message ListUserIdentitiesResp { - repeated UserIdentity identities = 1; -} - -// DeleteUserIdentityReq is a request to delete a user identity. -// This is a full data purge for GDPR compliance and account deletion. -// It cascades to: auth session, all refresh tokens, offline sessions, the -// password record (matched by the identity's email), and the identity itself. -message DeleteUserIdentityReq { - string user_id = 1; - string connector_id = 2; -} - -// DeleteUserIdentityResp returns the result of deleting a user identity. -message DeleteUserIdentityResp { - bool not_found = 1; -} - -// ResetMFAReq is a request to clear all MFA secrets and WebAuthn credentials for a user. -// Use when a user has lost access to all their MFA devices. -message ResetMFAReq { - string user_id = 1; - string connector_id = 2; -} - -// ResetMFAResp returns the result of resetting MFA. -message ResetMFAResp { - bool not_found = 1; -} - -// ListMFADevicesReq is a request to list registered MFA authenticators for a user. -message ListMFADevicesReq { - string user_id = 1; - string connector_id = 2; -} - -// ListMFADevicesResp returns MFA device information. -// Secret values and public keys are never included in the response. -message ListMFADevicesResp { - repeated MFADeviceInfo devices = 1; -} - -// DeleteWebAuthnCredentialReq is a request to delete a specific WebAuthn credential. -// Use when a user has lost or wants to deregister a specific security key. -message DeleteWebAuthnCredentialReq { - string user_id = 1; - string connector_id = 2; - bytes credential_id = 3; -} - -// DeleteWebAuthnCredentialResp returns the result of deleting a WebAuthn credential. -message DeleteWebAuthnCredentialResp { - bool not_found = 1; -} - -// DeleteMFASecretReq is a request to delete a specific MFA authenticator secret. -// Also removes any associated WebAuthn credentials for the same authenticator. -message DeleteMFASecretReq { - string user_id = 1; - string connector_id = 2; - string authenticator_id = 3; -} - -// DeleteMFASecretResp returns the result of deleting an MFA secret. -message DeleteMFASecretResp { - bool not_found = 1; -} - -// RevokeConsentReq is a request to revoke consent for a specific client. -// The user will see the consent screen again on next authorization. -message RevokeConsentReq { - string user_id = 1; - string connector_id = 2; - string client_id = 3; -} - -// RevokeConsentResp returns the result of revoking consent. -message RevokeConsentResp { - bool not_found = 1; -} - -// Dex represents the dex gRPC service. -service Dex { - // GetClient gets a client. - rpc GetClient(GetClientReq) returns (GetClientResp) {}; - // CreateClient creates a client. - rpc CreateClient(CreateClientReq) returns (CreateClientResp) {}; - // UpdateClient updates an existing client - rpc UpdateClient(UpdateClientReq) returns (UpdateClientResp) {}; - // DeleteClient deletes the provided client. - rpc DeleteClient(DeleteClientReq) returns (DeleteClientResp) {}; - // ListClients lists all client entries. - rpc ListClients(ListClientReq) returns (ListClientResp) {}; - // CreatePassword creates a password. - rpc CreatePassword(CreatePasswordReq) returns (CreatePasswordResp) {}; - // UpdatePassword modifies existing password. - rpc UpdatePassword(UpdatePasswordReq) returns (UpdatePasswordResp) {}; - // DeletePassword deletes the password. - rpc DeletePassword(DeletePasswordReq) returns (DeletePasswordResp) {}; - // ListPassword lists all password entries. - rpc ListPasswords(ListPasswordReq) returns (ListPasswordResp) {}; - // CreateConnector creates a connector. - rpc CreateConnector(CreateConnectorReq) returns (CreateConnectorResp) {}; - // UpdateConnector modifies existing connector. - rpc UpdateConnector(UpdateConnectorReq) returns (UpdateConnectorResp) {}; - // DeleteConnector deletes the connector. - rpc DeleteConnector(DeleteConnectorReq) returns (DeleteConnectorResp) {}; - // ListConnectors lists all connector entries. - rpc ListConnectors(ListConnectorReq) returns (ListConnectorResp) {}; - // GetVersion returns version information of the server. - rpc GetVersion(VersionReq) returns (VersionResp) {}; - // GetDiscovery returns discovery information of the server. - rpc GetDiscovery(DiscoveryReq) returns (DiscoveryResp) {}; - // ListRefresh lists all the refresh token entries for a particular user. - rpc ListRefresh(ListRefreshReq) returns (ListRefreshResp) {}; - // RevokeRefresh revokes the refresh token for the provided user-client pair. - // - // Note that each user-client pair can have only one refresh token at a time. - rpc RevokeRefresh(RevokeRefreshReq) returns (RevokeRefreshResp) {}; - // VerifyPassword returns whether a password matches a hash for a specific email or not. - rpc VerifyPassword(VerifyPasswordReq) returns (VerifyPasswordResp) {}; - // GetAuthSession returns an auth session by its ID. - rpc GetAuthSession(GetAuthSessionReq) returns (GetAuthSessionResp) {}; - // ListAuthSessions lists auth sessions, optionally filtered by user and connector. - rpc ListAuthSessions(ListAuthSessionsReq) returns (ListAuthSessionsResp) {}; - // DeleteAuthSession deletes an auth session and revokes associated refresh tokens. - rpc DeleteAuthSession(DeleteAuthSessionReq) returns (DeleteAuthSessionResp) {}; - // TerminateSessionsByConnector terminates all sessions for a connector and revokes associated refresh tokens. - rpc TerminateSessionsByConnector(TerminateSessionsByConnectorReq) returns (TerminateSessionsByConnectorResp) {}; - // TerminateSessionsByUser terminates all sessions for a user and revokes associated refresh tokens. - rpc TerminateSessionsByUser(TerminateSessionsByUserReq) returns (TerminateSessionsByUserResp) {}; - // GetUserIdentity returns a user identity by user and connector ID. - rpc GetUserIdentity(GetUserIdentityReq) returns (GetUserIdentityResp) {}; - // ListUserIdentities lists all user identities. - rpc ListUserIdentities(ListUserIdentitiesReq) returns (ListUserIdentitiesResp) {}; - // DeleteUserIdentity performs a full data purge for GDPR compliance: deletes the identity, - // auth session, refresh tokens, and offline sessions. - rpc DeleteUserIdentity(DeleteUserIdentityReq) returns (DeleteUserIdentityResp) {}; - // ResetMFA clears all MFA secrets and WebAuthn credentials for a user. - rpc ResetMFA(ResetMFAReq) returns (ResetMFAResp) {}; - // ListMFADevices lists registered MFA authenticators for a user. - rpc ListMFADevices(ListMFADevicesReq) returns (ListMFADevicesResp) {}; - // DeleteWebAuthnCredential deletes a specific WebAuthn credential. - rpc DeleteWebAuthnCredential(DeleteWebAuthnCredentialReq) returns (DeleteWebAuthnCredentialResp) {}; - // DeleteMFASecret deletes a specific MFA authenticator and its associated WebAuthn credentials. - rpc DeleteMFASecret(DeleteMFASecretReq) returns (DeleteMFASecretResp) {}; - // RevokeConsent revokes consent for a specific client. - rpc RevokeConsent(RevokeConsentReq) returns (RevokeConsentResp) {}; -} diff --git a/src/utils/userUtils.test.ts b/src/utils/userUtils.test.ts index 748f58eb3..9dcfd5986 100644 --- a/src/utils/userUtils.test.ts +++ b/src/utils/userUtils.test.ts @@ -1,4 +1,4 @@ -import { Password } from 'src/generated/dex/api' +import { Password } from '@linode/dex-client-grpc' import { deriveDexGroups, dexPasswordToUser } from './userUtils' function password(overrides: Partial = {}): Password { From 16a430ef3c10bdf7cbe307b76df5b5c791c641d2 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Tue, 29 Sep 2026 15:47:48 +0200 Subject: [PATCH 15/22] fix: add github token to build Signed-off-by: Cas Lubbers --- .github/workflows/coverage.yml | 2 +- .github/workflows/main.yml | 5 +++++ .github/workflows/release-software.yml | 10 ++++++++++ .npmrc | 2 +- Dockerfile | 5 ++++- package.json | 8 ++++---- 6 files changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b9dfa775d..5629e7d6c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -19,7 +19,7 @@ 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 "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" >> .npmrc diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5be1c5e73..925689d3c 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 21c505099..f0930d8bf 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/linode" >> .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 74b80ab39..c81d3547a 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/linode engine-strict=true diff --git a/Dockerfile b/Dockerfile index 671922a02..4b1075f51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,13 @@ 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 +# @linode/dex-client-grpc is hosted on GitHub Packages, which requires auth even for public pkg +RUN --mount=type=secret,id=NPM_TOKEN \ + echo "//npm.pkg.github.com/:_authToken=$(cat /run/secrets/NPM_TOKEN)" >> .npmrc RUN npm ci COPY . .* ./ diff --git a/package.json b/package.json index e1ed21f0d..c615aab2a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "author": "RedKubes", "bugs": { - "url": "https://github.com/redkubes/otomi-api/issues" + "url": "https://github.com/linode/apl-api/issues" }, "config": { "commitizen": { @@ -114,7 +114,7 @@ "node": ">=24 <25" }, "engineStrict": true, - "homepage": "https://github.com/redkubes/otomi-api#readme", + "homepage": "https://github.com/linode/apl-api#readme", "license": "ISC", "lint-staged": { "*.{js,ts,json,md,yml,yaml}": [ @@ -122,14 +122,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", From 947030d6db4646dabb3764ccdc067a6dd5b8891e Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Tue, 29 Sep 2026 16:13:53 +0200 Subject: [PATCH 16/22] fix: review comments Signed-off-by: Cas Lubbers --- Dockerfile | 8 +++++--- src/clients/dexClient.integration.test.ts | 21 --------------------- src/openapi/user.yaml | 4 ++-- 3 files changed, 7 insertions(+), 26 deletions(-) delete mode 100644 src/clients/dexClient.integration.test.ts diff --git a/Dockerfile b/Dockerfile index 4b1075f51..18fd9f14f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,10 +13,12 @@ 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 -# @linode/dex-client-grpc is hosted on GitHub Packages, which requires auth even for public pkg +# @linode/dex-client-grpc is hosted on GitHub Packages, which requires auth even for public pkg. RUN --mount=type=secret,id=NPM_TOKEN \ - echo "//npm.pkg.github.com/:_authToken=$(cat /run/secrets/NPM_TOKEN)" >> .npmrc -RUN npm ci + 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/src/clients/dexClient.integration.test.ts b/src/clients/dexClient.integration.test.ts deleted file mode 100644 index 1822f2e39..000000000 --- a/src/clients/dexClient.integration.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createDexPassword, deleteDexPassword, updateDexPassword } from './dexClient' - -const describeIfDexAvailable = process.env.DEX_GRPC_ADDRESS ? describe : describe.skip - -describeIfDexAvailable('dexClient integration (requires a running fork-built Dex)', () => { - const email = `dex-integration-test-${Date.now()}@example.com` - - it('creates, updates groups, and deletes a password record end to end', async () => { - await createDexPassword({ - id: 'integration-test-uuid', - email, - passwordHash: '$2a$10$abcdefghijklmnopqrstuuVGm5ZQeXk6b2ZQeXk6b2ZQeXk6b', - username: 'dex-integration-test', - groups: ['team-blue'], - }) - - await updateDexPassword({ email, newGroups: ['team-blue', 'team-admin'] }) - - await deleteDexPassword(email) - }) -}) diff --git a/src/openapi/user.yaml b/src/openapi/user.yaml index 159264b5e..a38fa1ece 100644 --- a/src/openapi/user.yaml +++ b/src/openapi/user.yaml @@ -116,8 +116,8 @@ User: initialPassword: type: string minLength: 8 - maxLength: 72 - description: The initial password of the user. With Dex as issuer, an admin may set this on create; otherwise one is generated. Must be between 8 and 72 bytes UTF-8 encoded (bcrypt only consumes the first 72 bytes). + 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 type: object From 89de000e84aab1dfbed0946059e3a7df987d3a9b Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Wed, 30 Sep 2026 16:56:17 +0200 Subject: [PATCH 17/22] feat: move oidc settings under otomi Signed-off-by: Cas Lubbers --- src/fileStore/file-map.ts | 8 ---- src/openapi/settings.yaml | 99 +++++++++++++++++++-------------------- src/otomi-models.ts | 3 +- 3 files changed, 48 insertions(+), 62 deletions(-) diff --git a/src/fileStore/file-map.ts b/src/fileStore/file-map.ts index 9d0c76434..6e09dcf30 100644 --- a/src/fileStore/file-map.ts +++ b/src/fileStore/file-map.ts @@ -77,14 +77,6 @@ export function getFileMaps(envDir: string): Map { name: 'obj', }) - maps.set('AplIdentityProvider', { - kind: 'AplIdentityProvider', - envDir, - pathGlob: `${envDir}/env/settings/*oidc.yaml`, - pathTemplate: 'env/settings/oidc.yaml', - name: 'oidc', - }) - maps.set('AplCapabilitySet', { kind: 'AplCapabilitySet', envDir, diff --git a/src/openapi/settings.yaml b/src/openapi/settings.yaml index bf6ba50f7..3d7ec5716 100644 --- a/src/openapi/settings.yaml +++ b/src/openapi/settings.yaml @@ -182,51 +182,6 @@ Settings: platformAdmin: [read-any, update-any] teamAdmin: [] teamMember: [] - oidc: - title: OIDC - additionalProperties: false - properties: - issuer: - type: string - $ref: 'definitions.yaml#/url' - clientID: - title: Client ID - type: string - clientSecret: - title: Client Secret - type: string - x-secret: true - platformAdminGroupID: - title: Platform admin group ID. - type: string - allTeamsAdminGroupID: - title: All teams admin group ID. - type: string - teamAdminGroupID: - title: Team admin group ID. - type: string - usernameClaimMapper: - description: Claim name used by Keycloak to identify incoming users from the identity provider. - default: '${CLAIM.upn}' - type: string - subClaimMapper: - 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: - platformAdmin: [read-any, update-any] - teamAdmin: [] - teamMember: [] - required: - - issuer - - clientID - - clientSecret otomi: title: Platform type: object @@ -282,13 +237,53 @@ Settings: platformAdmin: [read-any, update-any] teamAdmin: [] teamMember: [] - issuer: - type: string - enum: - - dex - - keycloak - default: keycloak - description: Which app every consumer authenticates against. + oidc: + type: object + additionalProperties: false + description: 'Holds many parts used in different locations. Please see keycloak, grafana, istio and oauth-proxy as those are all consuming (parts of) these settings.' + properties: + authenticationLayer: + type: string + enum: + - dex + - keycloak + default: keycloak + description: Which app every consumer authenticates against. + issuer: + $ref: 'definitions.yaml#/url' + clientID: + $ref: 'definitions.yaml#/wordCharacterPattern' + x-secret: '' + clientSecret: + type: string + x-secret: '' + platformAdminGroupID: + $ref: 'definitions.yaml#/wordCharacterPattern' + description: Keycloak only + allTeamsAdminGroupID: + $ref: 'definitions.yaml#/wordCharacterPattern' + description: Keycloak only + teamAdminGroupID: + $ref: 'definitions.yaml#/wordCharacterPattern' + description: Keycloak only + usernameClaimMapper: + $ref: 'definitions.yaml#/wordCharacterPattern' + description: Claim name from identity provider used by Keycloak to create the username. Best to not change this from the default. (Keycloak only) + default: '${CLAIM.upn}' + subClaimMapper: + type: string + description: Claim name passed by Keycloak as a unique user identifier. Best to not change this from the default. (Keycloak only) + default: sub + groupsClaimMapper: + type: string + description: Claim name the identity provider uses for group membership. Some providers require a namespaced name instead of "groups". (Dex only) + default: groups + scopes: + type: array + description: OIDC scopes requested from the external identity provider. (Dex only) + items: + type: string + default: [openid, profile, email, groups] x-acl: platformAdmin: [read-any, update-any] teamAdmin: [] diff --git a/src/otomi-models.ts b/src/otomi-models.ts index 832a26b41..4dad37579 100644 --- a/src/otomi-models.ts +++ b/src/otomi-models.ts @@ -71,8 +71,8 @@ export type Cluster = Settings['cluster'] export type Dns = Settings['dns'] export type Ingress = Settings['ingress'] export type Kms = Settings['kms'] -export type Oidc = Settings['oidc'] export type Otomi = Settings['otomi'] +export type Oidc = NonNullable['oidc'] export type Versions = Settings['versions'] export type AplRequestObject = @@ -108,7 +108,6 @@ export const APL_KINDS = [ 'AplIngress', 'AplObjectStorage', 'AplKms', - 'AplIdentityProvider', 'AplCapabilitySet', 'AplBackupCollection', 'AplPlatformSettingSet', From da51d881d4837612e699fd7b0aada5e9da31ef9e Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Thu, 1 Oct 2026 11:47:17 +0200 Subject: [PATCH 18/22] Revert "feat: move oidc settings under otomi" This reverts commit 89de000e84aab1dfbed0946059e3a7df987d3a9b. --- src/fileStore/file-map.ts | 8 ++++ src/openapi/settings.yaml | 99 ++++++++++++++++++++------------------- src/otomi-models.ts | 3 +- 3 files changed, 62 insertions(+), 48 deletions(-) diff --git a/src/fileStore/file-map.ts b/src/fileStore/file-map.ts index 6e09dcf30..9d0c76434 100644 --- a/src/fileStore/file-map.ts +++ b/src/fileStore/file-map.ts @@ -77,6 +77,14 @@ export function getFileMaps(envDir: string): Map { name: 'obj', }) + maps.set('AplIdentityProvider', { + kind: 'AplIdentityProvider', + envDir, + pathGlob: `${envDir}/env/settings/*oidc.yaml`, + pathTemplate: 'env/settings/oidc.yaml', + name: 'oidc', + }) + maps.set('AplCapabilitySet', { kind: 'AplCapabilitySet', envDir, diff --git a/src/openapi/settings.yaml b/src/openapi/settings.yaml index 3d7ec5716..bf6ba50f7 100644 --- a/src/openapi/settings.yaml +++ b/src/openapi/settings.yaml @@ -182,6 +182,51 @@ Settings: platformAdmin: [read-any, update-any] teamAdmin: [] teamMember: [] + oidc: + title: OIDC + additionalProperties: false + properties: + issuer: + type: string + $ref: 'definitions.yaml#/url' + clientID: + title: Client ID + type: string + clientSecret: + title: Client Secret + type: string + x-secret: true + platformAdminGroupID: + title: Platform admin group ID. + type: string + allTeamsAdminGroupID: + title: All teams admin group ID. + type: string + teamAdminGroupID: + title: Team admin group ID. + type: string + usernameClaimMapper: + description: Claim name used by Keycloak to identify incoming users from the identity provider. + default: '${CLAIM.upn}' + type: string + subClaimMapper: + 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: + platformAdmin: [read-any, update-any] + teamAdmin: [] + teamMember: [] + required: + - issuer + - clientID + - clientSecret otomi: title: Platform type: object @@ -237,53 +282,13 @@ Settings: platformAdmin: [read-any, update-any] teamAdmin: [] teamMember: [] - oidc: - type: object - additionalProperties: false - description: 'Holds many parts used in different locations. Please see keycloak, grafana, istio and oauth-proxy as those are all consuming (parts of) these settings.' - properties: - authenticationLayer: - type: string - enum: - - dex - - keycloak - default: keycloak - description: Which app every consumer authenticates against. - issuer: - $ref: 'definitions.yaml#/url' - clientID: - $ref: 'definitions.yaml#/wordCharacterPattern' - x-secret: '' - clientSecret: - type: string - x-secret: '' - platformAdminGroupID: - $ref: 'definitions.yaml#/wordCharacterPattern' - description: Keycloak only - allTeamsAdminGroupID: - $ref: 'definitions.yaml#/wordCharacterPattern' - description: Keycloak only - teamAdminGroupID: - $ref: 'definitions.yaml#/wordCharacterPattern' - description: Keycloak only - usernameClaimMapper: - $ref: 'definitions.yaml#/wordCharacterPattern' - description: Claim name from identity provider used by Keycloak to create the username. Best to not change this from the default. (Keycloak only) - default: '${CLAIM.upn}' - subClaimMapper: - type: string - description: Claim name passed by Keycloak as a unique user identifier. Best to not change this from the default. (Keycloak only) - default: sub - groupsClaimMapper: - type: string - description: Claim name the identity provider uses for group membership. Some providers require a namespaced name instead of "groups". (Dex only) - default: groups - scopes: - type: array - description: OIDC scopes requested from the external identity provider. (Dex only) - items: - type: string - default: [openid, profile, email, groups] + issuer: + type: string + enum: + - dex + - keycloak + default: keycloak + description: Which app every consumer authenticates against. x-acl: platformAdmin: [read-any, update-any] teamAdmin: [] diff --git a/src/otomi-models.ts b/src/otomi-models.ts index 4dad37579..832a26b41 100644 --- a/src/otomi-models.ts +++ b/src/otomi-models.ts @@ -71,8 +71,8 @@ export type Cluster = Settings['cluster'] export type Dns = Settings['dns'] export type Ingress = Settings['ingress'] export type Kms = Settings['kms'] +export type Oidc = Settings['oidc'] export type Otomi = Settings['otomi'] -export type Oidc = NonNullable['oidc'] export type Versions = Settings['versions'] export type AplRequestObject = @@ -108,6 +108,7 @@ export const APL_KINDS = [ 'AplIngress', 'AplObjectStorage', 'AplKms', + 'AplIdentityProvider', 'AplCapabilitySet', 'AplBackupCollection', 'AplPlatformSettingSet', From 26afd28741bc0a4f426281985b0936ca4d528df5 Mon Sep 17 00:00:00 2001 From: Cas Lubbers Date: Thu, 1 Oct 2026 11:48:04 +0200 Subject: [PATCH 19/22] fix: keep oidc top-level, add otomi.oidc.authenticationLayer only Co-Authored-By: Claude Sonnet 5 --- src/openapi/settings.yaml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/openapi/settings.yaml b/src/openapi/settings.yaml index bf6ba50f7..1fe61454d 100644 --- a/src/openapi/settings.yaml +++ b/src/openapi/settings.yaml @@ -282,13 +282,17 @@ Settings: platformAdmin: [read-any, update-any] teamAdmin: [] teamMember: [] - issuer: - type: string - enum: - - dex - - keycloak - default: keycloak - description: Which app every consumer authenticates against. + 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: [] From b117d97517be5217b97becc9f3870f457f3834e6 Mon Sep 17 00:00:00 2001 From: CasLubbers Date: Fri, 2 Oct 2026 09:57:23 +0200 Subject: [PATCH 20/22] chore: Update npm registry URL in coverage.yml Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5629e7d6c..145609a1d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -21,7 +21,7 @@ jobs: env: 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' From ded2120ec6ec417a4b6ebf85cfe78f096e0a6461 Mon Sep 17 00:00:00 2001 From: CasLubbers Date: Fri, 2 Oct 2026 09:57:47 +0200 Subject: [PATCH 21/22] chore: Update npm registry URL for Linode in workflow Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release-software.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-software.yml b/.github/workflows/release-software.yml index f0930d8bf..9b7dc5a11 100644 --- a/.github/workflows/release-software.yml +++ b/.github/workflows/release-software.yml @@ -131,7 +131,7 @@ jobs: env: 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 - name: Build OpenAPI schema From 99d6fd067a9127646481ecc2db1a971aeea395a3 Mon Sep 17 00:00:00 2001 From: CasLubbers Date: Fri, 2 Oct 2026 09:58:48 +0200 Subject: [PATCH 22/22] chore: Update npm registry configuration for linode Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .npmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.npmrc b/.npmrc index c81d3547a..774199166 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,3 @@ # The redkubes at github packages is a proxy for all npm packages. -@linode:registry=https://npm.pkg.github.com/linode +@linode:registry=https://npm.pkg.github.com engine-strict=true