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
This commit is contained in:
Gergő Jedlicska
2024-08-13 11:04:40 +02:00
committed by GitHub
parent 298d8d6e52
commit 5818a44e62
14 changed files with 359 additions and 0 deletions
+2
View File
@@ -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
+2
View File
@@ -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
+1
View File
@@ -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
@@ -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.
@@ -0,0 +1,14 @@
import { z } from 'zod'
const EnabledModules = z.object({
workspaces: z.boolean()
})
export type EnabledModules = z.infer<typeof EnabledModules>
export const LicenseTokenClaims = z.object({
allowedDomains: z.string().array(),
enabledModules: EnabledModules
})
export type LicenseTokenClaims = z.infer<typeof LicenseTokenClaims>
@@ -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<boolean> => {
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<jose.KeyLike> => {
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<boolean> => {
if (isDevEnv()) return true
const licenseToken = getLicenseToken()
if (!licenseToken) return false
const publicKey = await getPublicKey()
const canonicalUrl = getServerOrigin()
return validateLicenseModuleAccess({
licenseToken,
canonicalUrl,
publicKey,
requiredModules
})
}
@@ -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
})
})
})
@@ -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'
}
@@ -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) {
+1
View File
@@ -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",
@@ -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 }}
@@ -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": {
+8
View File
@@ -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
##
+8
View File
@@ -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"