From 5818a44e62e0e2fa7b5d6c91d1f828124956f334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Jedlicska?= <57442769+gjedlicska@users.noreply.github.com> Date: Tue, 13 Aug 2024 11:04:40 +0200 Subject: [PATCH] Gatekeeper (#2572) * feat(gatekeeper): initial license validation * test(gatekeeper): add license token to tests * chore(gatekeeper): cleanup * chore(gatekeeper): hide from circleci * feat(helm): load license token from secrets * chore(circleci): remove unused env var --- .circleci/config.yml | 2 + .gitguardian.yml | 2 + LICENSE | 1 + packages/server/modules/gatekeeper/LICENSE | 13 ++ .../server/modules/gatekeeper/domain/types.ts | 14 ++ .../gatekeeper/services/validateLicense.ts | 83 ++++++++ .../gatekeeper/tests/validateLicense.spec.ts | 194 ++++++++++++++++++ .../modules/shared/helpers/envHelper.ts | 4 + packages/server/modules/workspaces/index.ts | 9 + packages/server/package.json | 1 + .../speckle-server/templates/_helpers.tpl | 5 + utils/helm/speckle-server/values.schema.json | 15 ++ utils/helm/speckle-server/values.yaml | 8 + yarn.lock | 8 + 14 files changed, 359 insertions(+) create mode 100644 packages/server/modules/gatekeeper/LICENSE create mode 100644 packages/server/modules/gatekeeper/domain/types.ts create mode 100644 packages/server/modules/gatekeeper/services/validateLicense.ts create mode 100644 packages/server/modules/gatekeeper/tests/validateLicense.spec.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index c22ce27b5..7425ed357 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -17,6 +17,8 @@ workflows: - hotfix* - test-server: + context: + - speckle-server-licensing filters: &filters-allow-all tags: # run tests for any commit on any branch, including any tags diff --git a/.gitguardian.yml b/.gitguardian.yml index c54a0a2e7..837d08b2c 100644 --- a/.gitguardian.yml +++ b/.gitguardian.yml @@ -8,4 +8,6 @@ secret: name: '.circleci/deployment/manifests/speckle-server.secret.yaml - test s3_secret_key' - match: 9f1d96876edbf847bb792754025ed131374869e60866d5e9c349c9423b37dd09 name: '.circleci/deployment/manifests/speckle-server.secret.yaml - test session_secret' + - match: 9bf360c5ce31170e8e3cb30e275b2c00224dd97b93282491c60fb1665fac3845 + name: local test license version: 2 diff --git a/LICENSE b/LICENSE index d60131bfa..b3e6dedac 100644 --- a/LICENSE +++ b/LICENSE @@ -3,6 +3,7 @@ Copyright (c) 2020-present AEC Systems. Portions of this software are licensed as follows: - All content residing under the "packages/server/modules/workspaces/" directory of this repository is licensed under "The Speckle Enterprise Edition (EE) license". +- All content residing under the "packages/server/modules/gatekeeper/" directory of this repository is licensed under "The Speckle Enterprise Edition (EE) license". - Content outside of the above mentioned directories or restrictions above is available under the "Apache License" license as defined below. Apache License diff --git a/packages/server/modules/gatekeeper/LICENSE b/packages/server/modules/gatekeeper/LICENSE new file mode 100644 index 000000000..8fbdc0d3c --- /dev/null +++ b/packages/server/modules/gatekeeper/LICENSE @@ -0,0 +1,13 @@ +The Speckle Enterprise Edition (EE) license (the “EE License”) + +Copyright (c) 2024-present AEC Systems LTD. + +With regard to the Speckle Software: + +This software and associated documentation files (the "Software") may only be used, if you (and any entity that you represent) have agreed to, and are in compliance with, the AEC Systems Terms of Service, available at https://speckle.systems/terms/ (the “EE Terms”), or other agreement governing the use of the Software, as agreed by you and AEC Systems, and otherwise have a valid Speckle Enterprise Edition subscription for the correct number of user seats. Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that AEC Systems and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Speckle Enterprise Edition subscription for the correct number of user seats. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription. You agree that AEC Systems and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or sell the Software. + +This EE License applies only to the part of this Software that is not distributed as part of Speckle Community Edition (CE). Any part of this Software distributed as part of Speckle CE or is served client-side as an image, font, cascading stylesheet (CSS), file which produces or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or in part, is copyrighted under the Apache 2.0 license. The full text of this EE License shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +For all third party components incorporated into the Speckle Software, those components are licensed under the original license provided by the owner of the applicable component. \ No newline at end of file diff --git a/packages/server/modules/gatekeeper/domain/types.ts b/packages/server/modules/gatekeeper/domain/types.ts new file mode 100644 index 000000000..0a425384a --- /dev/null +++ b/packages/server/modules/gatekeeper/domain/types.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +const EnabledModules = z.object({ + workspaces: z.boolean() +}) + +export type EnabledModules = z.infer + +export const LicenseTokenClaims = z.object({ + allowedDomains: z.string().array(), + enabledModules: EnabledModules +}) + +export type LicenseTokenClaims = z.infer diff --git a/packages/server/modules/gatekeeper/services/validateLicense.ts b/packages/server/modules/gatekeeper/services/validateLicense.ts new file mode 100644 index 000000000..df39a9e65 --- /dev/null +++ b/packages/server/modules/gatekeeper/services/validateLicense.ts @@ -0,0 +1,83 @@ +import * as jose from 'jose' +import { + isDevEnv, + getServerOrigin, + getLicenseToken +} from '@/modules/shared/helpers/envHelper' +import { LicenseTokenClaims, EnabledModules } from '@/modules/gatekeeper/domain/types' + +type LicensedModuleNames = (keyof EnabledModules)[] + +export const validateLicenseModuleAccess = async ({ + licenseToken, + canonicalUrl, + publicKey, + requiredModules +}: { + licenseToken: string + canonicalUrl: string + publicKey: jose.KeyLike + requiredModules: LicensedModuleNames +}): Promise => { + try { + const { payload } = await jose.jwtVerify(licenseToken, publicKey) + + const claims = LicenseTokenClaims.safeParse(payload) + if (!claims.success) return false + + // make sure we match the allowedDomains + if (!claims.data.allowedDomains.includes(canonicalUrl)) return false + + const enabledModules = claims.data.enabledModules + for (const moduleName of requiredModules) { + if (enabledModules[moduleName as keyof EnabledModules] !== true) return false + } + return true + } catch (err) { + if (err instanceof jose.errors.JOSEError) { + // I'm deliberately hiding all internal details here, if any checks fail, its an invalid token + return false + } + } + return false +} + +let publicKey: jose.KeyLike | undefined + +const getPublicKey = async (): Promise => { + if (!publicKey) { + const alg = 'RS256' + const publicKeyString = `-----BEGIN PUBLIC KEY----- + MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAu3KR87R/7UTLAKqHyzIs + 00jfLd4jFw6WCKzRQv87QDcu/WAiHzBJtgys7RWmMxCN2wkbpDG80GjSsB+/yRDc + cjw5eF+nPYsvCzyQVHaVJnhsa2P2qSXIWucSGnHhpgPkL7Rm5xCCoNzYWmn83S83 + haw+vYMVURNdcTfj+6vXimRodnDJe644Jna5Xp3hs1PVzuMvDwAUaNQdki/2/0is + al3J8WsbtAJcah59flDODPu5BpMwbd0ZgixWBfCOuJvD5T5v7d7di8gY21t7OnJ8 + 1+6zAR3EKKHqWN2Wf8BvwiC8AXjUkSizqLhEnyhDC3IJ9I0zpu7gtqKYdBRj87wz + icHb8zyKZq6nxEEk3jxUfNYYy41//w3l9j6trvhFn88fd1ZuIlVq3xS1RC7176UA + LoKwZqDMZAJj5sIASmr13eKyuLvFMmB8jBedC4O5iW6FPq/+wnC+Td2TyssdSKi1 + VUj/fs12T81Xk2HYqxx+qLhSlFA3aocciQNZHvd3muyfAgMBAAE= + -----END PUBLIC KEY----- + ` + publicKey = await jose.importSPKI(publicKeyString, alg) + } + return publicKey +} + +export const validateModuleLicense = async ({ + requiredModules +}: { + requiredModules: LicensedModuleNames +}): Promise => { + if (isDevEnv()) return true + const licenseToken = getLicenseToken() + if (!licenseToken) return false + const publicKey = await getPublicKey() + const canonicalUrl = getServerOrigin() + return validateLicenseModuleAccess({ + licenseToken, + canonicalUrl, + publicKey, + requiredModules + }) +} diff --git a/packages/server/modules/gatekeeper/tests/validateLicense.spec.ts b/packages/server/modules/gatekeeper/tests/validateLicense.spec.ts new file mode 100644 index 000000000..d0dd49ca8 --- /dev/null +++ b/packages/server/modules/gatekeeper/tests/validateLicense.spec.ts @@ -0,0 +1,194 @@ +import type { LicenseTokenClaims } from '@/modules/gatekeeper/domain/types' +import { validateLicenseModuleAccess } from '@/modules/gatekeeper/services/validateLicense' +import { expect } from 'chai' +import cryptoRandomString from 'crypto-random-string' +import * as jose from 'jose' + +describe('validateLicense @gatekeeper', () => { + describe('validateLicenseModuleAccess', () => { + it('fails is the token is giberish', async () => { + const alg = 'RS256' + const { publicKey } = await jose.generateKeyPair(alg) + + const result = await validateLicenseModuleAccess({ + licenseToken: cryptoRandomString({ length: 32 }), + canonicalUrl: 'https://example.com', + publicKey, + requiredModules: ['workspaces'] + }) + + expect(result).to.be.false + }) + it('fails if the token is signed by another private key', async () => { + const canonicalUrl = 'https://example.com' + const alg = 'RS256' + + const { publicKey } = await jose.generateKeyPair(alg) + + const claims: LicenseTokenClaims = { + allowedDomains: [canonicalUrl], + enabledModules: { + workspaces: true + } + } + + const { privateKey } = await jose.generateKeyPair(alg) + + const licenseToken = await new jose.SignJWT(claims) + .setProtectedHeader({ alg }) + .setIssuedAt() + .sign(privateKey) + + const result = await validateLicenseModuleAccess({ + licenseToken, + canonicalUrl, + publicKey, + requiredModules: ['workspaces'] + }) + + expect(result).to.be.false + }) + it('fails if the token is not in the correct payload format', async () => { + const canonicalUrl = 'https://example.com' + const alg = 'RS256' + + const { privateKey, publicKey } = await jose.generateKeyPair(alg) + + const claims = { + enabledModules: { + workspaces: true + } + } + + const licenseToken = await new jose.SignJWT(claims) + .setProtectedHeader({ alg }) + .setIssuedAt() + .sign(privateKey) + + const result = await validateLicenseModuleAccess({ + licenseToken, + canonicalUrl, + publicKey, + requiredModules: ['workspaces'] + }) + + expect(result).to.be.false + }) + it('fails if the token domain claim does not include the canonicalUrl', async () => { + const canonicalUrl = 'https://example.com' + const alg = 'RS256' + + const { privateKey, publicKey } = await jose.generateKeyPair(alg) + + const claims: LicenseTokenClaims = { + allowedDomains: ['https://not.allowed'], + enabledModules: { + workspaces: true + } + } + + const licenseToken = await new jose.SignJWT(claims) + .setProtectedHeader({ alg }) + .setIssuedAt() + .sign(privateKey) + + const result = await validateLicenseModuleAccess({ + licenseToken, + canonicalUrl, + publicKey, + requiredModules: ['workspaces'] + }) + + expect(result).to.be.false + }) + it('fails if the token module claims do not enable all the required modules', async () => { + const canonicalUrl = 'https://example.com' + const alg = 'RS256' + + const { privateKey, publicKey } = await jose.generateKeyPair(alg) + + const claims: LicenseTokenClaims = { + allowedDomains: [canonicalUrl], + enabledModules: { + workspaces: false + } + } + + const licenseToken = await new jose.SignJWT(claims) + .setProtectedHeader({ alg }) + .setIssuedAt() + .sign(privateKey) + + const result = await validateLicenseModuleAccess({ + licenseToken, + canonicalUrl, + publicKey, + requiredModules: ['workspaces'] + }) + + expect(result).to.be.false + }) + it('fails if the token string is tampered with', async () => { + const canonicalUrl = 'https://example.com' + const alg = 'RS256' + + const { privateKey, publicKey } = await jose.generateKeyPair(alg) + + const claims: LicenseTokenClaims = { + allowedDomains: [canonicalUrl], + enabledModules: { + workspaces: true + } + } + + const licenseToken = await new jose.SignJWT(claims) + .setProtectedHeader({ alg }) + .setIssuedAt() + .sign(privateKey) + + const hackedToken = licenseToken + .split('.') + .map((value, index) => { + if (index === 1) return `${value}hack` + return value + }) + .join('.') + + const result = await validateLicenseModuleAccess({ + licenseToken: hackedToken, + canonicalUrl, + publicKey, + requiredModules: ['workspaces'] + }) + + expect(result).to.be.false + }) + it('succeeds for valid tokens', async () => { + const canonicalUrl = 'https://example.com' + const alg = 'RS256' + + const { privateKey, publicKey } = await jose.generateKeyPair(alg) + + const claims: LicenseTokenClaims = { + allowedDomains: [canonicalUrl], + enabledModules: { + workspaces: true + } + } + + const licenseToken = await new jose.SignJWT(claims) + .setProtectedHeader({ alg }) + .setIssuedAt() + .sign(privateKey) + + const result = await validateLicenseModuleAccess({ + licenseToken, + canonicalUrl, + publicKey, + requiredModules: ['workspaces'] + }) + + expect(result).to.be.true + }) + }) +}) diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index 2091f87df..bb7e6c507 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -394,6 +394,10 @@ export function getGendoAIAPIEndpoint() { export const getFeatureFlags = () => Environment.getFeatureFlags() +export function getLicenseToken(): string | undefined { + return process.env.LICENSE_TOKEN +} + export function isEmailEnabled() { return process.env.EMAIL === 'true' } diff --git a/packages/server/modules/workspaces/index.ts b/packages/server/modules/workspaces/index.ts index 079fcc6b4..1767af801 100644 --- a/packages/server/modules/workspaces/index.ts +++ b/packages/server/modules/workspaces/index.ts @@ -15,6 +15,7 @@ import { getStream, grantStreamPermissions } from '@/modules/core/repositories/s import { updateWorkspaceRoleFactory } from '@/modules/workspaces/services/management' import { getEventBus } from '@/modules/shared/services/eventBus' import { getStreams } from '@/modules/core/services/streams' +import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense' const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags() @@ -31,6 +32,14 @@ const initRoles = async () => { const workspacesModule: SpeckleModule = { async init(_, isInitial) { if (!FF_WORKSPACES_MODULE_ENABLED) return + const isWorkspaceLicenseValid = await validateModuleLicense({ + requiredModules: ['workspaces'] + }) + + if (!isWorkspaceLicenseValid) + throw new Error( + 'The workspaces module needs a valid license to run, contact Speckle to get one.' + ) moduleLogger.info('⚒️ Init workspaces module') if (isInitial) { diff --git a/packages/server/package.json b/packages/server/package.json index d06382068..311bfce59 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -72,6 +72,7 @@ "graphql-scalars": "^1.18.0", "graphql-subscriptions": "^2.0.0", "ioredis": "^5.2.2", + "jose": "^5.6.3", "knex": "^2.4.1", "libsodium-wrappers": "^0.7.13", "lodash": "^4.17.21", diff --git a/utils/helm/speckle-server/templates/_helpers.tpl b/utils/helm/speckle-server/templates/_helpers.tpl index 80f5624ea..9646aa71d 100644 --- a/utils/helm/speckle-server/templates/_helpers.tpl +++ b/utils/helm/speckle-server/templates/_helpers.tpl @@ -566,6 +566,11 @@ Generate the environment variables for Speckle server and Speckle objects deploy - name: FF_WORKSPACES_MODULE_ENABLED value: {{ .Values.featureFlags.workspaceModuleEnabled | quote }} +- name: LICENSE_TOKEN + valueFrom: + secretKeyRef: + name: "{{ default .Values.secretName .Values.server.licenseTokenSecret.secretName }}" + key: {{ default "license_token" .Values.server.licenseTokenSecret.secretKey }} - name: FF_MULTIPLE_EMAILS_MODULE_ENABLED value: {{ .Values.featureFlags.multipleEmailsModuleEnabled | quote }} diff --git a/utils/helm/speckle-server/values.schema.json b/utils/helm/speckle-server/values.schema.json index b8c931727..5d8aad7c9 100644 --- a/utils/helm/speckle-server/values.schema.json +++ b/utils/helm/speckle-server/values.schema.json @@ -641,6 +641,21 @@ } } }, + "licenseTokenSecret": { + "type": "object", + "properties": { + "secretName": { + "type": "string", + "description": "The name of the Kubernetes Secret containing the Session secret. This is a unique value (can be generated randomly). This is expected to be provided within the Kubernetes cluster as an opaque Kubernetes Secret. Ref: https://kubernetes.io/docs/concepts/configuration/secret/#opaque-secrets", + "default": "" + }, + "secretKey": { + "type": "string", + "description": "The key within the Kubernetes Secret holding the Session secret as its value.", + "default": "" + } + } + }, "sessionSecret": { "type": "object", "properties": { diff --git a/utils/helm/speckle-server/values.yaml b/utils/helm/speckle-server/values.yaml index a1ed4e520..d39af1a71 100644 --- a/utils/helm/speckle-server/values.yaml +++ b/utils/helm/speckle-server/values.yaml @@ -457,6 +457,14 @@ server: encryptionKeys: path: '/encryption-keys/keys.json' + licenseTokenSecret: + ## @param server.licenseTokenSecret.secretName The name of the Kubernetes Secret containing the Session secret. This is a unique value (can be generated randomly). This is expected to be provided within the Kubernetes cluster as an opaque Kubernetes Secret. Ref: https://kubernetes.io/docs/concepts/configuration/secret/#opaque-secrets + ## + secretName: '' + ## @param server.licenseTokenSecret.secretKey The key within the Kubernetes Secret holding the Session secret as its value. + ## + secretKey: '' + sessionSecret: ## @param server.sessionSecret.secretName The name of the Kubernetes Secret containing the Session secret. This is a unique value (can be generated randomly). This is expected to be provided within the Kubernetes cluster as an opaque Kubernetes Secret. Ref: https://kubernetes.io/docs/concepts/configuration/secret/#opaque-secrets ## diff --git a/yarn.lock b/yarn.lock index b6e146fc0..e0ae971a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15470,6 +15470,7 @@ __metadata: http-proxy-middleware: "npm:v3.0.0-beta.0" ioredis: "npm:^5.2.2" ioredis-mock: "npm:^8.9.0" + jose: "npm:^5.6.3" knex: "npm:^2.4.1" libsodium-wrappers: "npm:^0.7.13" lodash: "npm:^4.17.21" @@ -34868,6 +34869,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^5.6.3": + version: 5.6.3 + resolution: "jose@npm:5.6.3" + checksum: 10/829f07b8857221ada1cd112a5ebd084e34748f6e3247f5cb03aca0a1f25dd6a07283c9a4f5b854935bda505e3243405640550f8e9eb46c45b172dc4cb336ac89 + languageName: node + linkType: hard + "joycon@npm:^3.1.1": version: 3.1.1 resolution: "joycon@npm:3.1.1"