diff --git a/.env.example b/.env.example index 70554ed..57592ce 100644 --- a/.env.example +++ b/.env.example @@ -119,3 +119,4 @@ EGA_AUTH_REALM_NAME= EGA_API_URL= EGA_USERNAME= EGA_PASSWORD= +DAC_ID= diff --git a/README.md b/README.md index 3b98681..886c486 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Development of the Data Access Control API | EGA_API_URL | Root URL for EGA API | `string` | true | | | EGA_USERNAME | Username for account used to gain access token from EGA authentication server | `string` | true | | | EGA_PASSWORD | Password for account used to gain access token from EGA authentication server | `string` | true | | +| DAC_ID | AccessionId for ICGC DAC | `string` | true | | ## Feature Flags diff --git a/package-lock.json b/package-lock.json index 87eeb93..0d2002e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,8 @@ "uuid": "^8.3.2", "validate.js": "^0.13.1", "winston": "^3.3.3", - "yamljs": "^0.3.0" + "yamljs": "^0.3.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/bcrypt-nodejs": "^0.0.31", @@ -106,7 +107,7 @@ "sinon": "^10.0.0", "testcontainers": "^7.8.0", "ts-node-dev": "^2.0.0", - "typescript": "^4.9.5" + "typescript": "^5.6.2" } }, "node_modules/@aws-crypto/crc32": { @@ -882,14 +883,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==" }, - "node_modules/@aws-sdk/middleware-retry/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@aws-sdk/middleware-sdk-s3": { "version": "3.18.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.18.0.tgz", @@ -1143,98 +1136,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/is-array-buffer": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.18.0.tgz", - "integrity": "sha512-HvPRgESVQt0UbzRQZVKhf8SpGGc5Jrln3AtTzkVu6PBHO04Dh2EHsrsxiu7X3oB453Mnp8+LYBVIgsmM/RyJzA==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/middleware-stack": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.18.0.tgz", - "integrity": "sha512-+FDsKMRq3Gsd6ddVt1P+7ltSiRRcEj6KpRccMHkFkFqWWqn9OcPh+Et076ivSBXCW8q9Ib4qJi04hiCD/md2EQ==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/protocol-http": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.18.0.tgz", - "integrity": "sha512-GIKvZBEnm87/mRaVYHnsQDYBSvU6qyKjyVdHDpQHhF+MZ+MKafygmpdBjsrRRstWr7h5WepnUVImYgvmaW6vyw==", - "dependencies": { - "@aws-sdk/types": "3.18.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/signature-v4": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.18.0.tgz", - "integrity": "sha512-md52+v+aIDfhwtaN+xIJ+7XgSqtRmreGkSCnJziGINRSnUSdycoR/ZJhT5d9TbMpYHdoT0Rm9RXNXImlfKCNGw==", - "dependencies": { - "@aws-sdk/is-array-buffer": "3.18.0", - "@aws-sdk/types": "3.18.0", - "@aws-sdk/util-hex-encoding": "3.18.0", - "@aws-sdk/util-uri-escape": "3.18.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/smithy-client": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.18.0.tgz", - "integrity": "sha512-fIcfzrf2TnhB4W8UyqdPQ9fPAfIfuLQ0dO/Y9qwzsw0Bvj4qYYPcUaNI2raX7WN1G2KHa9wZdiceR0J+uQO7yg==", - "dependencies": { - "@aws-sdk/middleware-stack": "3.18.0", - "@aws-sdk/types": "3.18.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.18.0.tgz", - "integrity": "sha512-fyk6HXK1wk83n4fDvsG+ewV+yS4uegepeMNrmLr7iBKjzc/bLckTWk7GKFM5ZaF/9jWyk7o2eKW3C3BltgDrfQ==", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/util-hex-encoding": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.18.0.tgz", - "integrity": "sha512-tayCN0+jLJRyM7W059ybwaEojjI4ylP4UyyG+LDc4m62PskmsCWTWOJzudjtx4d765e0I/F1w1ELrE+VhUdOpQ==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/s3-request-presigner/node_modules/@aws-sdk/util-uri-escape": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.18.0.tgz", - "integrity": "sha512-Ui+uydvhzQALj/Q8sat4cVnCedwB/8iBPoMzcm1hr1r7ttWfmBKKElFZFl6ljCUtKaCE3rTb3JrZ2sKy9wT09A==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@aws-sdk/s3-request-presigner/node_modules/tslib": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", @@ -1433,38 +1334,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@aws-sdk/util-create-request/node_modules/@aws-sdk/middleware-stack": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.18.0.tgz", - "integrity": "sha512-+FDsKMRq3Gsd6ddVt1P+7ltSiRRcEj6KpRccMHkFkFqWWqn9OcPh+Et076ivSBXCW8q9Ib4qJi04hiCD/md2EQ==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/util-create-request/node_modules/@aws-sdk/smithy-client": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.18.0.tgz", - "integrity": "sha512-fIcfzrf2TnhB4W8UyqdPQ9fPAfIfuLQ0dO/Y9qwzsw0Bvj4qYYPcUaNI2raX7WN1G2KHa9wZdiceR0J+uQO7yg==", - "dependencies": { - "@aws-sdk/middleware-stack": "3.18.0", - "@aws-sdk/types": "3.18.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/util-create-request/node_modules/@aws-sdk/types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.18.0.tgz", - "integrity": "sha512-fyk6HXK1wk83n4fDvsG+ewV+yS4uegepeMNrmLr7iBKjzc/bLckTWk7GKFM5ZaF/9jWyk7o2eKW3C3BltgDrfQ==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@aws-sdk/util-create-request/node_modules/tslib": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", @@ -1483,38 +1352,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@aws-sdk/util-format-url/node_modules/@aws-sdk/querystring-builder": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.18.0.tgz", - "integrity": "sha512-1DrzflLp80RG674XfhZsl4jehIe0mdSPqXqMH6vOMDcmF/lLEsfwPs307G+Go3kwWXSUup52bcMmfi8Ef4xLBg==", - "dependencies": { - "@aws-sdk/types": "3.18.0", - "@aws-sdk/util-uri-escape": "3.18.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/util-format-url/node_modules/@aws-sdk/types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.18.0.tgz", - "integrity": "sha512-fyk6HXK1wk83n4fDvsG+ewV+yS4uegepeMNrmLr7iBKjzc/bLckTWk7GKFM5ZaF/9jWyk7o2eKW3C3BltgDrfQ==", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/@aws-sdk/util-format-url/node_modules/@aws-sdk/util-uri-escape": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.18.0.tgz", - "integrity": "sha512-Ui+uydvhzQALj/Q8sat4cVnCedwB/8iBPoMzcm1hr1r7ttWfmBKKElFZFl6ljCUtKaCE3rTb3JrZ2sKy9wT09A==", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@aws-sdk/util-format-url/node_modules/tslib": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", @@ -8572,9 +8409,9 @@ "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" }, "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -8751,15 +8588,15 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/uglify-js": { @@ -9466,9 +9303,9 @@ } }, "node_modules/zod": { - "version": "3.19.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.19.1.tgz", - "integrity": "sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -10230,11 +10067,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==" - }, - "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" } } }, @@ -10475,74 +10307,6 @@ "tslib": "^2.0.0" }, "dependencies": { - "@aws-sdk/is-array-buffer": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/is-array-buffer/-/is-array-buffer-3.18.0.tgz", - "integrity": "sha512-HvPRgESVQt0UbzRQZVKhf8SpGGc5Jrln3AtTzkVu6PBHO04Dh2EHsrsxiu7X3oB453Mnp8+LYBVIgsmM/RyJzA==", - "requires": { - "tslib": "^2.0.0" - } - }, - "@aws-sdk/middleware-stack": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.18.0.tgz", - "integrity": "sha512-+FDsKMRq3Gsd6ddVt1P+7ltSiRRcEj6KpRccMHkFkFqWWqn9OcPh+Et076ivSBXCW8q9Ib4qJi04hiCD/md2EQ==", - "requires": { - "tslib": "^2.0.0" - } - }, - "@aws-sdk/protocol-http": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/protocol-http/-/protocol-http-3.18.0.tgz", - "integrity": "sha512-GIKvZBEnm87/mRaVYHnsQDYBSvU6qyKjyVdHDpQHhF+MZ+MKafygmpdBjsrRRstWr7h5WepnUVImYgvmaW6vyw==", - "requires": { - "@aws-sdk/types": "3.18.0", - "tslib": "^2.0.0" - } - }, - "@aws-sdk/signature-v4": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4/-/signature-v4-3.18.0.tgz", - "integrity": "sha512-md52+v+aIDfhwtaN+xIJ+7XgSqtRmreGkSCnJziGINRSnUSdycoR/ZJhT5d9TbMpYHdoT0Rm9RXNXImlfKCNGw==", - "requires": { - "@aws-sdk/is-array-buffer": "3.18.0", - "@aws-sdk/types": "3.18.0", - "@aws-sdk/util-hex-encoding": "3.18.0", - "@aws-sdk/util-uri-escape": "3.18.0", - "tslib": "^2.0.0" - } - }, - "@aws-sdk/smithy-client": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.18.0.tgz", - "integrity": "sha512-fIcfzrf2TnhB4W8UyqdPQ9fPAfIfuLQ0dO/Y9qwzsw0Bvj4qYYPcUaNI2raX7WN1G2KHa9wZdiceR0J+uQO7yg==", - "requires": { - "@aws-sdk/middleware-stack": "3.18.0", - "@aws-sdk/types": "3.18.0", - "tslib": "^2.0.0" - } - }, - "@aws-sdk/types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.18.0.tgz", - "integrity": "sha512-fyk6HXK1wk83n4fDvsG+ewV+yS4uegepeMNrmLr7iBKjzc/bLckTWk7GKFM5ZaF/9jWyk7o2eKW3C3BltgDrfQ==" - }, - "@aws-sdk/util-hex-encoding": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-hex-encoding/-/util-hex-encoding-3.18.0.tgz", - "integrity": "sha512-tayCN0+jLJRyM7W059ybwaEojjI4ylP4UyyG+LDc4m62PskmsCWTWOJzudjtx4d765e0I/F1w1ELrE+VhUdOpQ==", - "requires": { - "tslib": "^2.0.0" - } - }, - "@aws-sdk/util-uri-escape": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.18.0.tgz", - "integrity": "sha512-Ui+uydvhzQALj/Q8sat4cVnCedwB/8iBPoMzcm1hr1r7ttWfmBKKElFZFl6ljCUtKaCE3rTb3JrZ2sKy9wT09A==", - "requires": { - "tslib": "^2.0.0" - } - }, "tslib": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", @@ -10731,29 +10495,6 @@ "tslib": "^2.0.0" }, "dependencies": { - "@aws-sdk/middleware-stack": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-stack/-/middleware-stack-3.18.0.tgz", - "integrity": "sha512-+FDsKMRq3Gsd6ddVt1P+7ltSiRRcEj6KpRccMHkFkFqWWqn9OcPh+Et076ivSBXCW8q9Ib4qJi04hiCD/md2EQ==", - "requires": { - "tslib": "^2.0.0" - } - }, - "@aws-sdk/smithy-client": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/smithy-client/-/smithy-client-3.18.0.tgz", - "integrity": "sha512-fIcfzrf2TnhB4W8UyqdPQ9fPAfIfuLQ0dO/Y9qwzsw0Bvj4qYYPcUaNI2raX7WN1G2KHa9wZdiceR0J+uQO7yg==", - "requires": { - "@aws-sdk/middleware-stack": "3.18.0", - "@aws-sdk/types": "3.18.0", - "tslib": "^2.0.0" - } - }, - "@aws-sdk/types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.18.0.tgz", - "integrity": "sha512-fyk6HXK1wk83n4fDvsG+ewV+yS4uegepeMNrmLr7iBKjzc/bLckTWk7GKFM5ZaF/9jWyk7o2eKW3C3BltgDrfQ==" - }, "tslib": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", @@ -10771,29 +10512,6 @@ "tslib": "^2.0.0" }, "dependencies": { - "@aws-sdk/querystring-builder": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/querystring-builder/-/querystring-builder-3.18.0.tgz", - "integrity": "sha512-1DrzflLp80RG674XfhZsl4jehIe0mdSPqXqMH6vOMDcmF/lLEsfwPs307G+Go3kwWXSUup52bcMmfi8Ef4xLBg==", - "requires": { - "@aws-sdk/types": "3.18.0", - "@aws-sdk/util-uri-escape": "3.18.0", - "tslib": "^2.0.0" - } - }, - "@aws-sdk/types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.18.0.tgz", - "integrity": "sha512-fyk6HXK1wk83n4fDvsG+ewV+yS4uegepeMNrmLr7iBKjzc/bLckTWk7GKFM5ZaF/9jWyk7o2eKW3C3BltgDrfQ==" - }, - "@aws-sdk/util-uri-escape": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-uri-escape/-/util-uri-escape-3.18.0.tgz", - "integrity": "sha512-Ui+uydvhzQALj/Q8sat4cVnCedwB/8iBPoMzcm1hr1r7ttWfmBKKElFZFl6ljCUtKaCE3rTb3JrZ2sKy9wT09A==", - "requires": { - "tslib": "^2.0.0" - } - }, "tslib": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", @@ -16570,9 +16288,9 @@ "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" }, "ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "requires": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -16692,9 +16410,9 @@ } }, "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==" + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==" }, "uglify-js": { "version": "3.13.9", @@ -17238,9 +16956,9 @@ } }, "zod": { - "version": "3.19.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.19.1.tgz", - "integrity": "sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==" + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" } } } diff --git a/package.json b/package.json index 92c70df..3b8fdab 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "sinon": "^10.0.0", "testcontainers": "^7.8.0", "ts-node-dev": "^2.0.0", - "typescript": "^4.9.5" + "typescript": "^5.6.2" }, "dependencies": { "@aws-sdk/client-s3": "^3.18.0", @@ -123,7 +123,8 @@ "uuid": "^8.3.2", "validate.js": "^0.13.1", "winston": "^3.3.3", - "yamljs": "^0.3.0" + "yamljs": "^0.3.0", + "zod": "^3.23.8" }, "husky": { "hooks": { diff --git a/src/config.ts b/src/config.ts index 4138772..939741f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -95,6 +95,7 @@ export interface AppConfig { authHost: string; authRealmName: string; apiUrl: string; + dacId: string; }; } @@ -211,6 +212,7 @@ const buildAppContext = (): AppConfig => { authHost: checkIsDefined(process.env.EGA_AUTH_HOST), authRealmName: checkIsDefined(process.env.EGA_AUTH_REALM_NAME), apiUrl: checkIsDefined(process.env.EGA_API_URL), + dacId: checkIsDefined(process.env.DAC_ID), }, }; return config; diff --git a/src/domain/interface.ts b/src/domain/interface.ts index 7fd995a..1c07f3f 100644 --- a/src/domain/interface.ts +++ b/src/domain/interface.ts @@ -368,12 +368,13 @@ export interface IRequest extends Request { identity: Identity; } -export interface UserDataFromApprovedApplicationsResult { +export type UserDataFromApprovedApplicationsResult = { applicant: Sections['applicant']; collaborators: Sections['collaborators']; lastUpdatedAtUtc?: Date; appId: string; -} + expiresAtUtc: Date; +}; export interface ApprovedUserRowData { userName: string; diff --git a/src/domain/service/applications/search.ts b/src/domain/service/applications/search.ts index 2584bea..937d7ee 100644 --- a/src/domain/service/applications/search.ts +++ b/src/domain/service/applications/search.ts @@ -347,6 +347,7 @@ export const getUsersFromApprovedApps = async (): Promise< 'sections.applicant': 1, 'sections.collaborators': 1, lastUpdatedAtUtc: 1, + expiresAtUtc: 1, }).exec(); return results.map((result) => { @@ -355,6 +356,7 @@ export const getUsersFromApprovedApps = async (): Promise< collaborators: result.sections.collaborators, appId: result.appId, lastUpdatedAtUtc: result.lastUpdatedAtUtc, + expiresAtUtc: result.expiresAtUtc, }; return approvedUsersInfo; }); diff --git a/src/jobs/ega/egaClient.ts b/src/jobs/ega/egaClient.ts index 66d61b4..56fbbee 100644 --- a/src/jobs/ega/egaClient.ts +++ b/src/jobs/ega/egaClient.ts @@ -17,25 +17,48 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import axios from 'axios'; +import axios, { AxiosError, AxiosHeaders } from 'axios'; import urlJoin from 'url-join'; import { getAppConfig } from '../../config'; -import getAppSecrets from '../../secrets'; -import { EGA_GRANT_TYPE, EGA_REALMS_PATH, EGA_TOKEN_ENDPOINT } from '../../utils/constants'; import logger from '../../logger'; +import getAppSecrets from '../../secrets'; -type IdpToken = { - access_token: string; - scope: string; - session_state: string; - token_type: 'Bearer'; - refresh_token: string; - refresh_expires_in: number; - expires_in: number; - 'not-before-policy': 0; -}; +import { + EGA_API, + EGA_GRANT_TYPE, + EGA_REALMS_PATH, + EGA_TOKEN_ENDPOINT, +} from '../../utils/constants'; +import { NotFoundError, TooManyRequestsError } from './errors'; +import { DacAccessionId, DatasetAccessionId } from './types/common'; +import { ApprovePermissionRequest, PermissionRequest, RevokePermission } from './types/requests'; +import { + ApprovePermissionResponse, + Dataset, + EgaPermission, + EgaPermissionRequest, + EgaUser, + IdpToken, + RevokePermissionResponse, +} from './types/responses'; +import { + ApprovedPermissionRequestsFailure, + CreatePermissionRequestsFailure, + failure, + GetDatasetsForDacFailure, + GetPermissionsByDatasetAndUserIdFailure, + GetPermissionsForDatasetFailure, + GetUserFailure, + Result, + RevokePermissionsFailure, + success, +} from './types/results'; +import { safeParseArray, ZodResultAccumulator } from './types/zodSafeParseArray'; +import { ApprovedUser, getErrorMessage } from './utils'; + +const { DACS, DATASETS, PERMISSIONS, REQUESTS, USERS } = EGA_API; -// initialize idp client +// initialize IDP client const initIdpClient = () => { const { ega: { authHost }, @@ -62,7 +85,7 @@ const apiAxiosClient = initApiAxiosClient(); /** * POST request to retrieve an accessToken for the EGA API client - * @returns Promise + * @returns Promise */ const getAccessToken = async (): Promise => { const { @@ -88,15 +111,23 @@ const getAccessToken = async (): Promise => { }, ); - const token = response.data; - return token; + const token = IdpToken.safeParse(response.data); + if (token.success) { + return token.data; + } + logger.error('Authentication with EGA failed.'); + throw new Error('Failed to retrieve access token'); }; +/** + * POST request to retrieve a new access token via refresh token flow + * @param token IdpToken + * @returns IdpToken + */ const refreshAccessToken = async (token: IdpToken): Promise => { const { ega: { authRealmName, clientId }, } = getAppConfig(); - const response = await idpClient.post( urlJoin(EGA_REALMS_PATH, authRealmName, EGA_TOKEN_ENDPOINT), { @@ -111,7 +142,12 @@ const refreshAccessToken = async (token: IdpToken): Promise => { }, ); - return response.data; + const result = IdpToken.safeParse(response.data); + if (result.success) { + return result.data; + } + logger.error('Refresh access token request failed.'); + throw new Error('Failed to refresh access token'); }; /** @@ -119,6 +155,9 @@ const refreshAccessToken = async (token: IdpToken): Promise => { * @returns API functions that use authenticated Axios instance */ export const egaApiClient = async () => { + const { + ega: { dacId }, + } = getAppConfig(); const token = await getAccessToken(); apiAxiosClient.defaults.headers.common['Authorization'] = `Bearer ${token.access_token}`; @@ -126,28 +165,290 @@ export const egaApiClient = async () => { apiAxiosClient.interceptors.response.use( (response) => response, async (error) => { - if (error.response && error.response.status === 401) { - logger.info('Access expired, attempting refresh'); - // Access token has expired, refresh it - try { - const newAccessToken = await refreshAccessToken(token); - // Update the request headers with the new access token - error.config.headers['Authorization'] = `Bearer ${newAccessToken.access_token}`; - // Retry the original request - return apiAxiosClient(error.config); - } catch (refreshError) { - // Handle token refresh error - throw refreshError; + if (error instanceof AxiosError) { + if (error.response && error.response.status === 401) { + logger.info('Access expired, attempting refresh'); + // Access token has expired, refresh it + try { + const newAccessToken = await refreshAccessToken(token); + // Update the request headers with the new access token + const headers = new AxiosHeaders(error.config?.headers); + headers.setAuthorization(`Bearer ${newAccessToken.access_token}`); + error.config = { + ...error.config, + headers, + }; + // Retry the original request + return apiAxiosClient(error.config); + } catch (refreshError) { + // Handle token refresh error + throw refreshError; + } + } + if (error.status === 404) { + throw new NotFoundError(error.message); + } + if (error.status === 429) { + throw new TooManyRequestsError(error.message); } } - return Promise.reject(error); + return new Response('Server error', { status: 500 }); }, ); - const getDacs = async () => { - const response = await apiAxiosClient.get('/dacs'); - return response.data; + /** + * GET request to retrieve all currently release datasets released for a DAC + * @param dacId DacAccessionId + * @returns ZodResultAccumulator + */ + const getDatasetsForDac = async ( + dacId: DacAccessionId, + ): Promise, GetDatasetsForDacFailure>> => { + const url = urlJoin(DACS, dacId, DATASETS); + try { + const { data } = await apiAxiosClient.get(url); + const result = safeParseArray(Dataset, data); + return success(result); + } catch (err) { + const errMessage = getErrorMessage(err, `Error retrieving datasets for DAC ${dacId}.`); + logger.error(`Error retrieving datasets for DAC ${dacId}.`); + return failure('SERVER_ERROR', errMessage); + } + }; + + /** + * Retrieve EGA user data for a DACO ApprovedUser + * @returns EGAUser + * @example + * // returns + * { + * id: 123, + * username: boysue@example.com, + * email: boysue@example.com, + * accession_id: EGAW00000009999 + * } + * getUser('boysue@example.com') + */ + const getUser = async (user: ApprovedUser): Promise> => { + const url = urlJoin(USERS, user.email); + try { + const { data } = await apiAxiosClient.get(url); + const egaUser = EgaUser.safeParse(data); + if (egaUser.success) { + return success(egaUser.data); + } + return failure('INVALID_USER', 'Failed to parse user response'); + } catch (err) { + if (err instanceof AxiosError) { + switch (err.code) { + case 'NOT_FOUND': + return failure('NOT_FOUND', 'User not found'); + default: + return failure('SERVER_ERROR', 'Axios error'); + } + } else { + const errMessage = getErrorMessage(err, 'Get user request failed'); + logger.error('Get user request failed'); + return failure('SERVER_ERROR', errMessage); + } + } }; - return { getDacs }; + /** + * GET request for list of existing permissions for a dataset + * Endpoint is paginated. + * @param datasetAccessionId: DatasetAccessionId + * @param limit number + * @param offset number + * @returns ZodResultAccumulator + */ + const getPermissionsForDataset = async ({ + datasetAccessionId, + limit, + offset, + }: { + datasetAccessionId: DatasetAccessionId; + limit: number; + offset: number; + }): Promise, GetPermissionsForDatasetFailure>> => { + const url = urlJoin(DACS, dacId, PERMISSIONS); + try { + const { data } = await apiAxiosClient.get(url, { + params: { + dataset_accession_id: datasetAccessionId, + limit, + offset, + }, + }); + + const result = safeParseArray(EgaPermission, data); + return success(result); + } catch (err) { + const errMessage = getErrorMessage(err, 'Get permissions for dataset request failed.'); + logger.error('Get permissions for dataset request failed.'); + return failure('SERVER_ERROR', errMessage); + } + }; + + /** + * GET request to retrieve existing dataset permissions for a user. + * One permission result is expected with userId and datasetId params, but response from EGA API comes as an array + * @param userId string + * @param datasetId DatasetAccessionId + * @returns ZodResultAccumulator + */ + const getPermissionByDatasetAndUserId = async ( + userId: number, + datasetId: DatasetAccessionId, + ): Promise< + Result, GetPermissionsByDatasetAndUserIdFailure> + > => { + try { + const url = urlJoin(DACS, dacId, PERMISSIONS); + const { data } = await apiAxiosClient.get(url, { + params: { + dataset_accession_id: datasetId, + user_id: userId, + }, + }); + const result = safeParseArray(EgaPermission, data); + return success(result); + } catch (err) { + const errMessage = getErrorMessage(err, 'Error retrieving permission for user'); + logger.error('Error retrieving permission for user'); + return failure('SERVER_ERROR', errMessage); + } + }; + + /** + * POST request to create PermissionRequests for a user + * @param requests PermissionRequest[] + * @returns ZodResultAccumulator + * @example + * // returns [ + * { + * "request_id": 1, + * "status": "pending", + * "request_data": { + * "comment": "I'd like to access the dataset" + * }, + * "date": "2024-01-31T16:24:13.725724+00:00", + * "username": "boysue", + * "full_name": "Boy Sue", + * "email": "boysue@example.com", + * "organisation": "Research Center", + * "dataset_accession_id": "EGAD00000000001", + * "dataset_title": "Dataset 8", + * "dac_accession_id": "EGAC00000000001", + * "dac_comment": "ticket", + * "dac_comment_edited_at": "2024-01-31T16:25:13.725724+00:00" + * } + * ] + * createPermissionRequests([{ + * username: "boysue", + * dac_accession_id: "EGAC00000000001", + * request_data: { + * "comment": "I'd like to access the dataset" + * }, + * }]) + */ + const createPermissionRequests = async ( + requests: PermissionRequest[], + ): Promise< + Result, CreatePermissionRequestsFailure> + > => { + try { + const { data } = await apiAxiosClient.post(REQUESTS, { + requests, + }); + const result = safeParseArray(EgaPermissionRequest, data); + return success(result); + } catch (err) { + const errMessage = getErrorMessage(err, 'Create permissions request failed.'); + logger.error('Create permissions request failed'); + return failure('SERVER_ERROR', errMessage); + } + }; + + /** + * Approves permissions by permission id. + * Endpoint accepts an array so multiple permissions can be approved in one request. + * @param requests + * @returns + * @example + * // returns { num_granted: 2 } + * revokePermissions( + * [ + * { request_id: 10, expires_at: "2025-01-31T16:25:13.725724+00:00" }, + * { request_id: 12, expires_at: "2026-01-31T16:25:13.725724+00:00" } + * ] + * ) + */ + const approvePermissionRequests = async ( + requests: ApprovePermissionRequest[], + ): Promise> => { + try { + const { data } = await apiAxiosClient.put(REQUESTS, { + requests, + }); + const result = ApprovePermissionResponse.safeParse(data); + if (result.success) { + return success(result.data); + } + return failure( + 'INVALID_APPROVE_PERMISSION_REQUESTS_RESPONSE', + 'Invalid response for approve permission requests.', + ); + } catch (err) { + const errMessage = getErrorMessage(err, 'Approve permissions requests failed.'); + logger.error('Create permissions request failed'); + return failure('SERVER_ERROR', errMessage); + } + }; + + /** + * Revokes permissions by permission id. + * Endpoint accepts an array so multiple permissions can be revoke in one request. + * @param requests RevokePermission[] + * @returns RevokePermissionResponse + * @example + * // returns { num_revoked: 2 } + * revokePermissions( + * [ + * { id: 10, reason: 'Access expired' }, + * { id: 12, reason: 'Access expired' } + * ] + * ) + */ + const revokePermissions = async ( + requests: RevokePermission[], + ): Promise> => { + try { + const { data } = await apiAxiosClient.delete(PERMISSIONS, { data: requests }); + const result = RevokePermissionResponse.safeParse(data); + if (result.success) { + return success(result.data); + } + return failure( + 'INVALID_REVOKE_PERMISSIONS_RESPONSE', + 'Invalid response from revoke permissions request.', + ); + } catch (err) { + const errMessage = getErrorMessage(err, 'Revoke permissions request failed'); + logger.error('Revoke permissions request failed'); + return failure('SERVER_ERROR', errMessage); + } + }; + + return { + approvePermissionRequests, + createPermissionRequests, + getDatasetsForDac, + getPermissionByDatasetAndUserId, + getPermissionsForDataset, + getUser, + revokePermissions, + }; }; + +export type EgaClient = Awaited>; diff --git a/src/jobs/ega/errors.ts b/src/jobs/ega/errors.ts new file mode 100644 index 0000000..764ba76 --- /dev/null +++ b/src/jobs/ega/errors.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { AxiosError } from 'axios'; + +/** + * Custom errors for Axios responses. + * Defines expected status and code values for error handling. + */ + +export class NotFoundError extends AxiosError { + constructor(message: string) { + super(message); + this.name = 'NotFound'; + this.status = 404; + this.code = 'NOT_FOUND'; + } +} + +export class TooManyRequestsError extends AxiosError { + constructor(message: string) { + super(message); + this.name = 'TooManyRequests'; + this.status = 429; + this.code = 'TOO_MANY_REQUESTS'; + } +} diff --git a/src/jobs/ega/types/common.ts b/src/jobs/ega/types/common.ts new file mode 100644 index 0000000..01f608d --- /dev/null +++ b/src/jobs/ega/types/common.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { z } from 'zod'; + +/* ******************* * + Dates + * ******************* */ + +// For YYYY-MM-DD Date strings (i.e. '2021-01-01') +export const DateString = z.string().date(); +export type DateString = z.infer; + +// For ISO8601 Datetime strings (i.e. '2021-01-01T00:00:00.000Z') +export const DateTime = z.string().datetime(); +export type DateTime = z.infer; + +/* ******************* * + Enums & Literals + * ******************* */ + +const DAC_STATUS_ENUM = ['accepted', 'pending', 'declined'] as const; +export const DacStatus = z.enum(DAC_STATUS_ENUM); +export type DacStatus = z.infer; + +export const IdpTokenType = z.literal('Bearer'); +export type IdpTokenType = z.infer; + +/* ******************* * + Regexes + * ******************* */ + +const DAC_ACCESSION_ID_REGEX = new RegExp(`^EGAC\\d{11}$`); +export const DacAccessionId = z.string().regex(DAC_ACCESSION_ID_REGEX); +export type DacAccessionId = z.infer; + +const DATASET_ACCESSION_ID_REGEX = new RegExp(`^EGAD\\d{11}$`); +export const DatasetAccessionId = z.string().regex(DATASET_ACCESSION_ID_REGEX); +export type DatasetAccessionId = z.infer; + +const USER_ACCESSION_ID_REGEX = new RegExp(`^EGAW\\d{11}$`); +export const UserAccessionId = z.string().regex(USER_ACCESSION_ID_REGEX); +export type UserAccessionId = z.infer; diff --git a/src/jobs/ega/types/requests.ts b/src/jobs/ega/types/requests.ts new file mode 100644 index 0000000..2fb2229 --- /dev/null +++ b/src/jobs/ega/types/requests.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { DatasetAccessionId, DateTime } from './common'; + +export type PermissionRequest = { + username: string; + dataset_accession_id: DatasetAccessionId; + request_data: { + comment: string; + }; +}; + +export type ApprovePermissionRequest = { + request_id: number; + expires_at: DateTime; +}; + +export type RevokePermission = { + id: number; + reason: string; +}; diff --git a/src/jobs/ega/types/responses.ts b/src/jobs/ega/types/responses.ts new file mode 100644 index 0000000..e299730 --- /dev/null +++ b/src/jobs/ega/types/responses.ts @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { z } from 'zod'; +import { + DacAccessionId, + DacStatus, + DatasetAccessionId, + DateString, + DateTime, + IdpTokenType, + UserAccessionId, +} from './common'; + +export const IdpToken = z.object({ + access_token: z.string(), + scope: z.string(), + session_state: z.string(), + token_type: IdpTokenType, + refresh_token: z.string(), + refresh_expires_in: z.number(), + expires_in: z.number(), + 'not-before-policy': z.number(), +}); +export type IdpToken = z.infer; + +export const Dac = z.object({ + provisional_id: z.number(), + accession_id: z.string(), + title: z.string(), + description: z.string(), + status: DacStatus, + declined_reason: z.string().nullable(), +}); +export type Dac = z.infer; + +export const Dataset = z.object({ + accession_id: DatasetAccessionId, + title: z.string(), + description: z.string().optional(), +}); +export type Dataset = z.infer; + +export const EgaUser = z.object({ + id: z.number(), + username: z.string(), + // several Users are coming back with null email values, is this expected? Assuming that if there is a userid, the User is valid + email: z.string().nullable(), + accession_id: UserAccessionId, +}); +export type EgaUser = z.infer; + +export const EgaPermissionRequest = z.object({ + request_id: z.number(), + status: z.string(), + request_data: z.object({ + comment: z.string(), + }), + // TODO: api docs state this should be a DateTime string, but receiving 'YYYY-MM-DD` string. May need to change to coerceable date? + date: DateString, + username: z.string(), + full_name: z.string(), + email: z.string().email(), + organisation: z.string(), + dataset_accession_id: DatasetAccessionId, + dataset_title: z.string().nullable(), + dac_accession_id: DacAccessionId, + dac_comment: z.string().nullable(), + dac_comment_edited_at: DateTime.nullable(), // TODO: api docs state this should be DateTime string, but need to verify +}); +export type EgaPermissionRequest = z.infer; + +export const EgaPermission = z.object({ + permission_id: z.number(), + username: z.string(), + user_accession_id: UserAccessionId, + dataset_accession_id: DatasetAccessionId, + dac_accession_id: DacAccessionId, +}); +export type EgaPermission = z.infer; + +export const ApprovePermissionResponse = z.object({ num_granted: z.number() }); +export type ApprovePermissionResponse = z.infer; + +export const RevokePermissionResponse = z.object({ num_revoked: z.number() }); +export type RevokePermissionResponse = z.infer; diff --git a/src/jobs/ega/types/results.ts b/src/jobs/ega/types/results.ts new file mode 100644 index 0000000..9ba2b55 --- /dev/null +++ b/src/jobs/ega/types/results.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +/* ******************* * + Success and Failure types + * ******************* */ + +export type Success = { status: 'SUCCESS'; data: T }; +export type Failure = { + status: FailureStatus; + message: string; + data: T; +}; + +/** + * Represents a response that on success will include data of type T, + * otherwise a message will be returned in place of the data explaining the failure with optional fallback data. + * The failure object has data type of void by default. + */ +export type Result = + | Success + | Failure; +/** + * Determines if the Result is a Success type by its status + * and returns the type predicate so TS can infer the Result as a Success + * @param result + * @returns {boolean} Whether the Result was a Success or not + */ + +/* ******************* * + Convenience methods + * ******************* */ + +export function isSuccess( + result: Result, +): result is Success { + return result.status === 'SUCCESS'; +} + +/** + * Create a successful response for a Result or Either type, with data of the success type + * @param {T} data + * @returns {Success} `{status: 'SUCCESS', data}` + */ +export const success = (data: T): Success => ({ status: 'SUCCESS', data }); + +/** + * Create a response indicating a failure with a status naming the reason and message describing the failure. + * @param {string} message + * @returns {Failure} `{status: string, message: string, data: undefined}` + */ +export const failure = ( + status: FailureStatus, + message: string, +): Failure => ({ + status, + message, + data: undefined, +}); + +/* ******************* * + Failure types + * ******************* */ + +export type ServerError = 'SERVER_ERROR'; +export type GetDatasetsForDacFailure = ServerError; +export type GetPermissionsForDatasetFailure = ServerError; +export type GetPermissionsByDatasetAndUserIdFailure = ServerError; +export type CreatePermissionRequestsFailure = ServerError; +export type ApprovedPermissionRequestsFailure = + | ServerError + | 'INVALID_APPROVE_PERMISSION_REQUESTS_RESPONSE'; +export type RevokePermissionsFailure = ServerError | 'INVALID_REVOKE_PERMISSIONS_RESPONSE'; +export type GetUserFailure = ServerError | 'NOT_FOUND' | 'INVALID_USER'; diff --git a/src/jobs/ega/types/zodSafeParseArray.ts b/src/jobs/ega/types/zodSafeParseArray.ts new file mode 100644 index 0000000..8151d05 --- /dev/null +++ b/src/jobs/ega/types/zodSafeParseArray.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { z, ZodError, ZodTypeAny } from 'zod'; + +export type ZodResultAccumulator = { success: T[]; failure: ZodError[] }; +/** + * Parses an array of Zod SafeParseReturnType results into success (successful parse) and failure (parsing error) + * @param acc ZodResultAccumulator + * @param item z.SafeParseReturnType + * @returns ZodResultAccumulator + */ +const resultReducer = (acc: ZodResultAccumulator, item: z.SafeParseReturnType) => { + if (item.success) { + acc.success.push(item.data); + } else { + acc.failure.push(item.error); + } + return acc; +}; + +/** + * Run Zod safeParse for Schema T on an array of items, and split results by SafeParseReturnType 'success' or 'error'. + * @params schema + * @params data unknown[] + * @returns { success: [], failure: [] } + */ +export const safeParseArray = ( + schema: T, + data: Array, +): ZodResultAccumulator> => + data + .map((i) => schema.safeParse(i)) + .reduce>>((acc, item) => resultReducer(acc, item), { + success: [], + failure: [], + }); diff --git a/src/jobs/ega/utils.ts b/src/jobs/ega/utils.ts new file mode 100644 index 0000000..f96d671 --- /dev/null +++ b/src/jobs/ega/utils.ts @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { uniqBy } from 'lodash'; +import { UserDataFromApprovedApplicationsResult } from '../../domain/interface'; +import { getUsersFromApprovedApps } from '../../domain/service/applications/search'; +import { DatasetAccessionId } from './types/common'; +import { PermissionRequest, RevokePermission } from './types/requests'; + +export type ApprovedUser = { + email: string; + appExpiry: Date; + appId: string; +}; + +/** + * Extracts fields necessary for EGA permissions flow from applicant and collaborators in an application + * @param applicationData UserDataFromApprovedApplicationsResult + * @returns ApprovedUser[] + */ +const parseApprovedUsersForApplication = ( + applicationData: UserDataFromApprovedApplicationsResult, +): ApprovedUser[] => { + const applicantInfo = applicationData.applicant.info; + const applicant = { + email: applicantInfo.institutionEmail, + appExpiry: applicationData.expiresAtUtc, + appId: applicationData.appId, + }; + const collabs = (applicationData.collaborators.list || []).map((collab) => ({ + email: collab.info.institutionEmail, + appExpiry: applicationData.expiresAtUtc, + appId: applicationData.appId, + })); + + return [applicant, ...collabs].flat(); +}; + +/** + * Retrieves applicant and collaborator information from all currently approved applications + * @returns Promise + */ +export const getApprovedUsers = async () => { + const results = await getUsersFromApprovedApps(); + const parsedUsers = results.map((app) => parseApprovedUsersForApplication(app)).flat(); + return uniqBy(parsedUsers, 'email'); +}; + +// Utils + +/** + * Create Ega permission request object for POST /requests + * @param username + * @param dataset_accession_id + * @returns PermissionRequest + */ +const createPermissionRequest = ( + username: string, + datasetAccessionId: DatasetAccessionId, +): PermissionRequest => { + return { + username, + dataset_accession_id: datasetAccessionId, + request_data: { + comment: 'Access granted by ICGC DAC', + }, + }; +}; + +/** + * Create revoke permission request object for DELETE /requests + * @param permissionId + * @returns RevokePermissionRequest + */ +const createRevokePermissionRequest = (permissionId: number): RevokePermission => { + return { + id: permissionId, + reason: 'ICGC DAC access has expired.', + }; +}; + +/** + * Checks if error arg is of type Error, and returns err.message if so; otherwise returns defaultMessage arg + * Used in catch block of try/catch, where type of error in catch is unknown + * @param error unknown + * @param defaultMessage string + * @returns string + */ +export const getErrorMessage = (error: unknown, defaultMessage: string): string => + error instanceof Error ? error.message : defaultMessage; diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 4be248e..8a07267 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -1,5 +1,5 @@ // sample jwts for local testing based on the keys below -export const userJwt = `eyJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE2MTk4MjU1NjUsImV4cCI6MTcwMTMwODc4MCwic3ViIjoiNDlmZWEyZDMtYmE0MS00OGQ5LWFjMjgtMDUxN2RhYzMwOWEyIiwiaXNzIjoiZWdvIiwianRpIjoiOGE2YzIwMWYtMzBmOS00ZmU5LTkzNjItNzdkOGZlMmZkYTk2IiwiY29udGV4dCI6eyJzY29wZSI6WyIqLkRFTlkiXSwidXNlciI6eyJlbWFpbCI6ImFwcGxpY2FudEBvaWNyLm9uLmNhIiwic3RhdHVzIjoiQVBQUk9WRUQiLCJmaXJzdE5hbWUiOiJhcHBsaSIsImxhc3ROYW1lIjoiY2FudCIsImNyZWF0ZWRBdCI6MTU4MzM0MjI5MTc0NSwibGFzdExvZ2luIjoxNjE5ODI1NTY1NjUxLCJwcmVmZXJyZWRMYW5ndWFnZSI6IkVOR0xJU0giLCJ0eXBlIjoiVVNFUiIsInByb3ZpZGVyVHlwZSI6IkdPT0dMRSIsInByb3ZpZGVyU3ViamVjdElkIjoiYXBwbGljYW50MTIzNCIsImdyb3VwcyI6WyI2NTI0Il19fSwiYXVkIjpbXX0.QBXpq0954YPnX4HUsRblBfaR0eY0HvprBN72IDPq3oaqHA2iG8cmjXMP-bj3KQPDdVbMaoCj7DRik7Zff-rvTrPAY_epjVqz8VOdd_fAhcXMj4b4MC3Zuc2-0l8Q8uXWHvUfERBW58XIF-IYCLsVHuopkn3s4YmRl7VM0dbqHr5c4Fv9gMSZP3oiD3zlpix-7WpQ2RSMfjQMul6rEDyt113q5t4OLV8d85Z9zUo4sfbhdoVig59IA9Y_9FDuVf274phfzF8v1IIs8prDcQqbNzqQ1fEqsZNEPuZ5x29cy8oMCTBXTboD_UdDvTFm1CouuUHXMFMPOuNERSl5qKu32A`; +export const userJwt = `eyJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE2MTk4MjU1NjUsImV4cCI6MTc5MDIxMTgwNiwic3ViIjoiNDlmZWEyZDMtYmE0MS00OGQ5LWFjMjgtMDUxN2RhYzMwOWEyIiwiaXNzIjoiZWdvIiwianRpIjoiOGE2YzIwMWYtMzBmOS00ZmU5LTkzNjItNzdkOGZlMmZkYTk2IiwiY29udGV4dCI6eyJzY29wZSI6WyIqLkRFTlkiXSwidXNlciI6eyJlbWFpbCI6ImFwcGxpY2FudEBvaWNyLm9uLmNhIiwic3RhdHVzIjoiQVBQUk9WRUQiLCJmaXJzdE5hbWUiOiJhcHBsaSIsImxhc3ROYW1lIjoiY2FudCIsImNyZWF0ZWRBdCI6MTU4MzM0MjI5MTc0NSwibGFzdExvZ2luIjoxNjE5ODI1NTY1NjUxLCJwcmVmZXJyZWRMYW5ndWFnZSI6IkVOR0xJU0giLCJ0eXBlIjoiVVNFUiIsInByb3ZpZGVyVHlwZSI6IkdPT0dMRSIsInByb3ZpZGVyU3ViamVjdElkIjoiYXBwbGljYW50MTIzNCIsImdyb3VwcyI6WyI2NTI0Il19fSwiYXVkIjpbXX0.jOCmon8NLgI2wXxWFFjS-utJVKiPr3QtAgxmiHUPnAZP2Eal9KdPujda3dsZOJIrXaoppAUMbaSFdJSO9Xi1P254bZmAdKuMJFQgDRLwaJKK50tPK-GJviWazsNWJ1AHko70vlehxETeMSv7yqaIbu3zFK_cLQYPsCSCoEmuxsEXOkMdlwUaRqGHtMaMuKyhFas2rs_zmkjbPkRiZx-AfaUPZsF-gCcYe1lKM5CTfKQt75ebqEXUYp1CCq3qeuYoGTEslC-qkyBOsL1B9RuDOMZkOs9TY9A4-V8qGO1ySB4kbaiJa5TEvOPTq8bsQKdA52AhQTjcaHN07jYQhPuadA`; export const reviewerJwt = `eyJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE2MTk4MjU1NjUsImV4cCI6MTc0ODU0MDQxMCwic3ViIjoiYWRtaW4xMjM0NSIsImlzcyI6ImVnbyIsImp0aSI6IjhhNmMyMDFmLTMwZjktNGZlOS05MzYyLTc3ZDhmZTJmZGE5NiIsImNvbnRleHQiOnsic2NvcGUiOlsiREFDTy1SRVZJRVcuV1JJVEUiLCJEQUNPLVJFVklFVy5SRUFEIl0sInVzZXIiOnsiZW1haWwiOiJiYWxsYWJhZGlAb2ljci5vbi5jYSIsInN0YXR1cyI6IkFQUFJPVkVEIiwiZmlyc3ROYW1lIjoiQmFzaGFyIiwibGFzdE5hbWUiOiJBbGxhYmFkaSIsImNyZWF0ZWRBdCI6MTU4MzM0MjI5MTc0NSwibGFzdExvZ2luIjoxNjE5ODI1NTY1NjUxLCJwcmVmZXJyZWRMYW5ndWFnZSI6IkZSRU5DSCIsInByb3ZpZGVyVHlwZSI6IkdPT0dMRSIsInByb3ZpZGVyU3ViamVjdElkIjoiZ29vZ2xlMTIyMzM0IiwidHlwZSI6IkFETUlOIiwiZ3JvdXBzIjpbIjY1MjQiXX19LCJhdWQiOltdfQ.Yne_TnFvEbkq5YzZtDiDCBdqYoB83sQMy8DOexKPJdsRpM5xfrZ7UMVnqcnQY_EV8sVAtStvwWPa5XRFDcNlWM3SJg7mBebueUJRqyYrgoyNIOl7IeQy0TOtLnuhRCojmDVvrH_HI1F9gl0DCtyFvjCgkS0RAXZ8PDtFBdV7O0uu2iK3Lw_t8NRhr6N3swl3xxGIKk5b_C2nrpgaCEI4qGYqLh9hLrYQcKEM_g2DKvSmvzkySYijquFCkxCESIVQvLhkrgM3j3zKcXD0qz9hlrKqElhS3-DidAay5uPRBT2Tz130Ub1_zm_voox9ixux4S1UgPfaRErNgEkX3Cp-YQ`; export const systemJwt = `eyJraWQiOiIyODc5Y2FiOC0zNWFiLTRlMDgtYmYzZS1kNzY4ZTcyYThiM2YiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMDA5OTgiLCJuYmYiOjE2NjkyMjMxNTgsInNjb3BlIjpbIkRBQ08tU1lTVEVNLldSSVRFIl0sImlzcyI6ImVnbyIsImNvbnRleHQiOnsic2NvcGUiOlsiREFDTy1TWVNURU0uV1JJVEUiLCJEQUNPLVNZU1RFTS5SRUFEIl0sImFwcGxpY2F0aW9uIjp7Im5hbWUiOiJEQUMtQVBQIiwiY2xpZW50SWQiOiJkYWMtYXBwIiwicmVkaXJlY3RVcmkiOiJyZWRpcmVjdCIsInN0YXR1cyI6IkFQUFJPVkVEIiwiZXJyb3JSZWRpcmVjdFVyaSI6ImVycm9yIiwidHlwZSI6IkNMSUVOVCJ9fSwiZXhwIjoxNzQ4NTQwNDEwLCJpYXQiOjE2NjkyMjMxNTgsImp0aSI6IjE0Yjc5NjgxLWFjNGEtNGU1Yi05NDU5LTBmYzYwOTVhY2NjZCJ9.KcpR3D6a0Q3D-tUcY9KiFN5THctAn8TShcpObdoaRSCmJSjcscMeY9hdmzmO-_XgPFKPdLC1dSRV7ZIq7FUQTrv3lZGflK_9fVMdW9YtuJy9XlsHKF5zwKZ0FUx6Qd0Ib1blD2THn8a-HyH2TWYblmTsE8mLBVmiuZc4Bfv62H-aTPfKSVYeRh7BBK-Jb2BBIKIkFW28noPXQwQK9Pv9iyWC04CnvqItKD3Ad3SLoRBSXporHLGRkfwygQ8EuusTp2zSpwIB6gg_zalmuwUKegpOLqCZUfq_Kk5iJLYnCZVNwdouT-pgkQ6hgjR208SczaZhPlKxh7Tic-d8gNYnkg`; export const readOnlyReviewerJwt = `eyJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE2MTk4MjU1NjUsImV4cCI6MTc0ODU0MDQxMCwic3ViIjoicm9hZG1pbjEyMzQ1IiwiaXNzIjoiZWdvIiwianRpIjoiOGE2YzIwMWYtMzBmOS00ZmU5LTkzNjItNzdkOGZlMmZkYTk2IiwiY29udGV4dCI6eyJzY29wZSI6WyJEQUNPLVJFVklFVy5SRUFEIl0sInVzZXIiOnsiZW1haWwiOiJyZWFkT25seUFkbWluQGV4YW1wbGUuY29tIiwic3RhdHVzIjoiQVBQUk9WRUQiLCJmaXJzdE5hbWUiOiJSZWFkIiwibGFzdE5hbWUiOiJPbmx5IiwiY3JlYXRlZEF0IjoxNTgzMzQyMjkxNzQ1LCJsYXN0TG9naW4iOjE2MTk4MjU1NjU2NTEsInByZWZlcnJlZExhbmd1YWdlIjoiRU5HTElTSCIsInByb3ZpZGVyVHlwZSI6IkdPT0dMRSIsInByb3ZpZGVyU3ViamVjdElkIjoicmVhZC1vbmx5LWdvb2dsZS0xMjMiLCJ0eXBlIjoiQURNSU4iLCJncm91cHMiOltdfX0sImF1ZCI6W119.IYmglxfg_7wPpwurY0I1J6OLFoAj1tZRLV8i4JVC06gKV9uV_iFZFkf8Jw2DE4E06N_bSWVo-ORl-gyVE0_mGfV_evAbheyOtRigEG0HgGSUzdjB8tnDJikrrJLHzaWpHaiI9gGmFUt8sm1lMtnOCykZrHQynpcBhxrI3GpqdUnAN5IS4Hrn___s2sfAYKfsVVBQCGkg_ityQajjG8QU7McYIHgC0YcIxzKQneFwkYhpD14N8OJWSw7PqDsRdawTVj3fkcu_zg1D8r-CW01cBWXpL2BF6FvdOHvzXW7zTZr3B67U2V8zOH_w9lPjkcigatgsiITJln5E6vI19EDRoQ`;