From c8d6aa4a2701d36be7e657cd3755aca2727b27b7 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Fri, 27 Jan 2023 12:59:09 +0100 Subject: [PATCH] feat: support new cdn tokens (#1061) --- integration-tests/package.json | 2 + integration-tests/testkit/flow.ts | 33 +- integration-tests/testkit/seed.ts | 6 +- .../tests/api/artifacts-cdn.spec.ts | 478 ++++++++++++++---- .../tests/api/schema/publish.spec.ts | 6 +- .../api/src/modules/cdn/module.graphql.ts | 76 ++- .../src/modules/cdn/providers/cdn.provider.ts | 228 +++++++-- .../services/api/src/modules/cdn/resolvers.ts | 76 ++- .../src/modules/shared/providers/storage.ts | 23 + packages/services/api/src/shared/is-uuid.ts | 6 + packages/services/api/tsconfig.json | 2 +- packages/services/cdn-worker/package.json | 1 + packages/services/cdn-worker/src/analytics.ts | 27 +- packages/services/cdn-worker/src/cdn-token.ts | 64 +++ .../services/cdn-worker/src/key-validation.ts | 145 +++++- packages/services/storage/src/index.ts | 137 ++++- .../[projectId]/[targetId]/settings.tsx | 9 +- .../target/settings/cdn-access-tokens.tsx | 464 +++++++++++++++++ .../web/app/src/components/v2/heading.tsx | 1 + .../components/v2/modals/connect-schema.tsx | 95 ++-- .../graphql/mutation.create-cdn-token.graphql | 6 - pnpm-lock.yaml | 4 +- tsconfig.json | 3 +- 23 files changed, 1635 insertions(+), 257 deletions(-) create mode 100644 packages/services/api/src/shared/is-uuid.ts create mode 100644 packages/services/cdn-worker/src/cdn-token.ts create mode 100644 packages/web/app/src/components/target/settings/cdn-access-tokens.tsx delete mode 100644 packages/web/app/src/graphql/mutation.create-cdn-token.graphql diff --git a/integration-tests/package.json b/integration-tests/package.json index ea1758783..2ddf7d024 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -13,11 +13,13 @@ "@app/gql": "link:./testkit/gql", "@aws-sdk/client-s3": "3.259.0", "@esm2cjs/execa": "6.1.1-cjs.1", + "@graphql-hive/client": "workspace:*", "@graphql-hive/core": "0.2.3", "@graphql-typed-document-node/core": "3.1.1", "@trpc/client": "10.9.0", "@trpc/server": "10.9.0", "@whatwg-node/fetch": "0.6.2", + "bcryptjs": "2.4.3", "date-fns": "2.29.3", "dockerode": "3.3.4", "dotenv": "16.0.3", diff --git a/integration-tests/testkit/flow.ts b/integration-tests/testkit/flow.ts index 1aca06c8c..3c112dbeb 100644 --- a/integration-tests/testkit/flow.ts +++ b/integration-tests/testkit/flow.ts @@ -879,16 +879,21 @@ export function schemaSyncCDN(input: SchemaSyncCdnInput, token: string) { export function createCdnAccess(selector: TargetSelectorInput, token: string) { return execute({ document: gql(/* GraphQL */ ` - mutation createCdnToken($selector: TargetSelectorInput!) { - createCdnToken(selector: $selector) { - url - token + mutation createCdnAccessToken($input: CreateCdnAccessTokenInput!) { + createCdnAccessToken(input: $input) { + ok { + secretAccessToken + cdnUrl + } + error { + message + } } } `), token, variables: { - selector, + input: { selector, alias: 'CDN Access Token' }, }, }); } @@ -898,11 +903,11 @@ export async function fetchSchemaFromCDN(selector: TargetSelectorInput, token: s r.expectNoGraphQLErrors(), ); - const cdn = cdnAccessResult.createCdnToken; + const cdn = cdnAccessResult.createCdnAccessToken.ok!; - const res = await fetch(cdn.url + '/sdl', { + const res = await fetch(cdn.cdnUrl + '/sdl', { headers: { - 'X-Hive-CDN-Key': cdn.token, + 'X-Hive-CDN-Key': cdn.secretAccessToken, }, }); @@ -917,11 +922,11 @@ export async function fetchSupergraphFromCDN(selector: TargetSelectorInput, toke r.expectNoGraphQLErrors(), ); - const cdn = cdnAccessResult.createCdnToken; + const cdn = cdnAccessResult.createCdnAccessToken.ok!; - const res = await fetch(cdn.url + '/supergraph', { + const res = await fetch(cdn.cdnUrl + '/supergraph', { headers: { - 'X-Hive-CDN-Key': cdn.token, + 'X-Hive-CDN-Key': cdn.secretAccessToken, }, }); @@ -938,11 +943,11 @@ export async function fetchMetadataFromCDN(selector: TargetSelectorInput, token: r.expectNoGraphQLErrors(), ); - const cdn = cdnAccessResult.createCdnToken; + const cdn = cdnAccessResult.createCdnAccessToken.ok!; - const res = await fetch(cdn.url + '/metadata', { + const res = await fetch(cdn.cdnUrl + '/metadata', { headers: { - 'X-Hive-CDN-Key': cdn.token, + 'X-Hive-CDN-Key': cdn.secretAccessToken, }, }); diff --git a/integration-tests/testkit/seed.ts b/integration-tests/testkit/seed.ts index c42d5aa97..5cee8a685 100644 --- a/integration-tests/testkit/seed.ts +++ b/integration-tests/testkit/seed.ts @@ -304,7 +304,9 @@ export function initSeed() { secret, ).then(r => r.expectNoGraphQLErrors()); - return result.createCdnToken; + expect(result.createCdnAccessToken.ok).not.toBeNull(); + + return result.createCdnAccessToken.ok!; }, async publishSchema(options: { sdl: string; @@ -376,7 +378,7 @@ export function initSeed() { return result.schemaVersions.nodes; }, async fetchTokenInfo() { - const tokenInfoResult = await readTokenInfo(secret!).then(r => + const tokenInfoResult = await readTokenInfo(secret).then(r => r.expectNoGraphQLErrors(), ); diff --git a/integration-tests/tests/api/artifacts-cdn.spec.ts b/integration-tests/tests/api/artifacts-cdn.spec.ts index 5bbc136c6..1235f64fb 100644 --- a/integration-tests/tests/api/artifacts-cdn.spec.ts +++ b/integration-tests/tests/api/artifacts-cdn.spec.ts @@ -1,3 +1,4 @@ +import crypto from 'crypto'; import bcrypt from 'bcryptjs'; import { ApolloGateway } from '@apollo/gateway'; import { ApolloServer } from '@apollo/server'; @@ -7,10 +8,13 @@ import { DeleteObjectsCommand, GetObjectCommand, ListObjectsCommand, + PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; import { createSupergraphManager } from '@graphql-hive/client'; import { fetch } from '@whatwg-node/fetch'; +import { gql } from '../../testkit/gql'; +import { execute } from '../../testkit/graphql'; import { initSeed } from '../../testkit/seed'; import { getServiceHost } from '../../testkit/utils'; @@ -53,6 +57,15 @@ async function deleteAllS3BucketObjects(s3Client: S3Client, bucketName: string) await deleteS3Object(s3Client, bucketName, keysToDelete); } +async function putS3Object(s3Client: S3Client, bucketName: string, key: string, body: string) { + const putObjectCommand = new PutObjectCommand({ + Bucket: bucketName, + Key: key, + Body: body, + }); + await s3Client.send(putObjectCommand); +} + async function fetchS3ObjectArtifact( bucketName: string, key: string, @@ -80,6 +93,17 @@ function buildEndpointUrl( return `${baseUrl}${targetId}/${resourceType}`; } +function generateLegacyToken(targetId: string) { + const encoder = new TextEncoder(); + return ( + crypto + // eslint-disable-next-line no-process-env + .createHmac('sha256', process.env.HIVE_ENCRYPTION_SECRET!) + .update(encoder.encode(targetId)) + .digest('base64') + ); +} + /** * We have both a CDN that runs as part of the server and one that runs as a standalone service (cloudflare worker). */ @@ -91,6 +115,97 @@ function runArtifactsCDNTests( getServiceHost(runtime.service, runtime.port).then(v => `http://${v}${runtime.path}`); describe(`Artifacts CDN ${name}`, () => { + test.concurrent('legacy cdn access key can be used for accessing artifacts', async () => { + const endpointBaseUrl = await getBaseEndpoint(); + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { target, createToken } = await createProject(ProjectType.Single); + const token = await createToken({ + targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite], + }); + + await token + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + // manually generate CDN access token for legacy support + const legacyToken = generateLegacyToken(target.id); + const legacyTokenHash = await bcrypt.hash(legacyToken, await bcrypt.genSalt(10)); + await putS3Object(s3Client, 'artifacts', `cdn-legacy-keys/${target.id}`, legacyTokenHash); + + const url = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'); + const response = await fetch(url, { + method: 'GET', + headers: { + 'x-hive-cdn-key': legacyToken, + }, + }); + + expect(response.status).toEqual(200); + expect(await response.text()).toMatchInlineSnapshot(` + "type Query { + ping: String + }" + `); + }); + + test.concurrent( + 'legacy deleting cdn access token from s3 revokes artifact cdn access', + async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject } = await createOrg(); + const { createToken, target } = await createProject(ProjectType.Single); + const writeToken = await createToken({ + targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite], + }); + + await writeToken + .publishSchema({ + author: 'Kamil', + commit: 'abc123', + sdl: `type Query { ping: String }`, + }) + .then(r => r.expectNoGraphQLErrors()); + + // manually generate CDN access token for legacy support + const legacyToken = generateLegacyToken(target.id); + const legacyTokenHash = await bcrypt.hash(legacyToken, await bcrypt.genSalt(10)); + await putS3Object(s3Client, 'artifacts', `cdn-legacy-keys/${target.id}`, legacyTokenHash); + + const endpointBaseUrl = await getBaseEndpoint(); + + // First roundtrip + const url = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'); + let response = await fetch(url, { + method: 'GET', + headers: { + 'x-hive-cdn-key': legacyToken, + }, + }); + expect(response.status).toEqual(200); + expect(await response.text()).toMatchInlineSnapshot(` + "type Query { + ping: String + }" + `); + + await deleteS3Object(s3Client, 'artifacts', [`cdn-legacy-keys/${target.id}`]); + + // Second roundtrip + response = await fetch(url, { + method: 'GET', + headers: { + 'x-hive-cdn-key': legacyToken, + }, + }); + expect(response.status).toEqual(403); + }, + ); + test.concurrent('access without credentials', async () => { const endpointBaseUrl = await getBaseEndpoint(); const url = buildEndpointUrl(endpointBaseUrl, 'i-do-not-exist', 'sdl'); @@ -127,87 +242,6 @@ function runArtifactsCDNTests( expect(response.headers.get('location')).toEqual(null); }); - test.concurrent('created (legacy) cdn access key is stored on S3', async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject } = await createOrg(); - const { createToken, target } = await createProject(ProjectType.Single); - const writeToken = await createToken({ - targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite], - }); - const cdnAccess = await writeToken.createCdnAccess(); - const result = await fetchS3ObjectArtifact('artifacts', `cdn-legacy-keys/${target.id}`); - const isMatch = await bcrypt.compare(cdnAccess.token, result.body); - expect(isMatch).toEqual(true); - }); - - test.concurrent('creating (legacy) cdn access token can be done multiple times', async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject } = await createOrg(); - const { createToken, target } = await createProject(ProjectType.Single); - const writeToken = await createToken({ - targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite], - }); - - let cdnAccess = await writeToken.createCdnAccess(); - const firstResult = await fetchS3ObjectArtifact('artifacts', `cdn-legacy-keys/${target.id}`); - let isMatch = await bcrypt.compare(cdnAccess.token, firstResult.body); - expect(isMatch).toEqual(true); - - cdnAccess = await writeToken.createCdnAccess(); - const secondResult = await fetchS3ObjectArtifact('artifacts', `cdn-legacy-keys/${target.id}`); - isMatch = await bcrypt.compare(cdnAccess.token, secondResult.body); - expect(isMatch).toEqual(true); - }); - - test.concurrent( - 'deleting (legacy) cdn access token from s3 revokes artifact cdn access', - async () => { - const { createOrg } = await initSeed().createOwner(); - const { createProject } = await createOrg(); - const { createToken, target } = await createProject(ProjectType.Single); - const writeToken = await createToken({ - targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.RegistryWrite], - }); - - await writeToken - .publishSchema({ - author: 'Kamil', - commit: 'abc123', - sdl: `type Query { ping: String }`, - }) - .then(r => r.expectNoGraphQLErrors()); - - const cdnAccess = await writeToken.createCdnAccess(); - const endpointBaseUrl = await getBaseEndpoint(); - - // First roundtrip - const url = buildEndpointUrl(endpointBaseUrl, target!.id, 'sdl'); - let response = await fetch(url, { - method: 'GET', - headers: { - 'x-hive-cdn-key': cdnAccess.token, - }, - }); - expect(response.status).toEqual(200); - expect(await response.text()).toMatchInlineSnapshot(` - "type Query { - ping: String - }" - `); - - await deleteS3Object(s3Client, 'artifacts', [`cdn-legacy-keys/${target.id}`]); - - // Second roundtrip - response = await fetch(url, { - method: 'GET', - headers: { - 'x-hive-cdn-key': cdnAccess.token, - }, - }); - expect(response.status).toEqual(403); - }, - ); - test.concurrent('access SDL artifact with valid credentials', async () => { const { createOrg } = await initSeed().createOwner(); const { createProject } = await createOrg(); @@ -228,11 +262,11 @@ function runArtifactsCDNTests( expect(publishSchemaResult.schemaPublish.__typename).toEqual('SchemaPublishSuccess'); const cdnAccessResult = await writeToken.createCdnAccess(); const endpointBaseUrl = await getBaseEndpoint(); - const url = buildEndpointUrl(endpointBaseUrl, target!.id, 'sdl'); + const url = buildEndpointUrl(endpointBaseUrl, target.id, 'sdl'); const response = await fetch(url, { method: 'GET', headers: { - 'x-hive-cdn-key': cdnAccessResult.token, + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, }, redirect: 'manual', }); @@ -243,7 +277,7 @@ function runArtifactsCDNTests( const artifactContents = await fetchS3ObjectArtifact( 'artifacts', - `artifact/${target!.id}/sdl`, + `artifact/${target.id}/sdl`, ); expect(artifactContents.body).toMatchInlineSnapshot(` "type Query { @@ -276,7 +310,7 @@ function runArtifactsCDNTests( // check if artifact exists in bucket const artifactContents = await fetchS3ObjectArtifact( 'artifacts', - `artifact/${target!.id}/services`, + `artifact/${target.id}/services`, ); expect(artifactContents.body).toMatchInlineSnapshot( `"[{"name":"ping","sdl":"type Query { ping: String }","url":"ping.com"}]"`, @@ -284,11 +318,11 @@ function runArtifactsCDNTests( const cdnAccessResult = await writeToken.createCdnAccess(); const endpointBaseUrl = await getBaseEndpoint(); - const url = buildEndpointUrl(endpointBaseUrl, target!.id, 'services'); + const url = buildEndpointUrl(endpointBaseUrl, target.id, 'services'); let response = await fetch(url, { method: 'GET', headers: { - 'x-hive-cdn-key': cdnAccessResult.token, + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, }, redirect: 'manual', }); @@ -338,7 +372,7 @@ function runArtifactsCDNTests( // check if artifact exists in bucket const artifactContents = await fetchS3ObjectArtifact( 'artifacts', - `artifact/${target!.id}/services`, + `artifact/${target.id}/services`, ); expect(artifactContents.body).toMatchInlineSnapshot( `"[{"name":"ping","sdl":"type Query { ping: String }","url":"ping.com"}]"`, @@ -346,11 +380,11 @@ function runArtifactsCDNTests( const cdnAccessResult = await writeToken.createCdnAccess(); const endpointBaseUrl = await getBaseEndpoint(); - const url = buildEndpointUrl(endpointBaseUrl, target!.id, 'services'); + const url = buildEndpointUrl(endpointBaseUrl, target.id, 'services'); const response = await fetch(url, { method: 'GET', headers: { - 'x-hive-cdn-key': cdnAccessResult.token, + 'x-hive-cdn-key': cdnAccessResult.secretAccessToken, 'if-none-match': artifactContents.eTag, }, redirect: 'manual', @@ -385,7 +419,7 @@ function runArtifactsCDNTests( const gateway = new ApolloGateway({ supergraphSdl: createSupergraphManager({ endpoint: endpointBaseUrl + target.id, - key: cdnAccessResult.token, + key: cdnAccessResult.secretAccessToken, }), }); @@ -432,3 +466,265 @@ function runArtifactsCDNTests( runArtifactsCDNTests('API Mirror', { service: 'server', port: 8082, path: '/artifacts/v1/' }); // runArtifactsCDNTests('Local CDN Mock', 'http://127.0.0.1:3004/artifacts/v1/'); + +describe('CDN token', () => { + const TargetCDNAccessTokensQuery = gql(/* GraphQL */ ` + query TargetCDNAccessTokens($selector: TargetSelectorInput!, $after: String, $first: Int = 2) { + target(selector: $selector) { + cdnAccessTokens(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + cursor + node { + id + firstCharacters + lastCharacters + createdAt + } + } + } + } + } + `); + + const DeleteCDNAccessTokenMutation = gql(/* GraphQL */ ` + mutation DeleteCDNAccessToken($input: DeleteCdnAccessTokenInput!) { + deleteCdnAccessToken(input: $input) { + error { + message + } + ok { + deletedCdnAccessTokenId + } + } + } + `); + + it('connection pagination', async () => { + const { createOrg } = await initSeed().createOwner(); + const { organization, createProject } = await createOrg(); + const { project, target, createToken } = await createProject(ProjectType.Federation); + + const token = await createToken({ + targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.Settings], + }); + + await Promise.all(new Array(5).fill(0).map(() => token.createCdnAccess())); + + let result = await execute({ + document: TargetCDNAccessTokensQuery, + variables: { + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + }, + authToken: token.secret, + }).then(r => r.expectNoGraphQLErrors()); + + expect(result.target!.cdnAccessTokens.edges).toHaveLength(2); + expect(result.target!.cdnAccessTokens.pageInfo.hasNextPage).toEqual(true); + let endCursor = result.target!.cdnAccessTokens.pageInfo.endCursor; + + result = await execute({ + document: TargetCDNAccessTokensQuery, + variables: { + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + after: endCursor, + }, + authToken: token.secret, + }).then(r => r.expectNoGraphQLErrors()); + + expect(result.target!.cdnAccessTokens.edges).toHaveLength(2); + expect(result.target!.cdnAccessTokens.pageInfo.hasNextPage).toEqual(true); + endCursor = result.target!.cdnAccessTokens.pageInfo.endCursor; + + result = await execute({ + document: TargetCDNAccessTokensQuery, + variables: { + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + after: endCursor, + }, + authToken: token.secret, + }).then(r => r.expectNoGraphQLErrors()); + + expect(result.target!.cdnAccessTokens.edges).toHaveLength(1); + expect(result.target!.cdnAccessTokens.pageInfo.hasNextPage).toEqual(false); + }); + + it('new created access tokens are added at the beginning of the connection', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { organization, createProject } = await createOrg(); + const { project, target, createToken } = await createProject(ProjectType.Federation); + + const token = await createToken({ + targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.Settings], + }); + + await token.createCdnAccess(); + + const firstResult = await execute({ + document: TargetCDNAccessTokensQuery, + variables: { + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + first: 2, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + const firstId = firstResult.target!.cdnAccessTokens.edges[0].node.id; + expect(firstResult.target!.cdnAccessTokens.edges).toHaveLength(1); + + await token.createCdnAccess(); + + const secondResult = await execute({ + document: TargetCDNAccessTokensQuery, + variables: { + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + first: 2, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + expect(secondResult.target!.cdnAccessTokens.edges).toHaveLength(2); + expect(secondResult.target!.cdnAccessTokens.edges[1].node.id).toEqual(firstId); + }); + + it('delete cdn access token', async () => { + const { createOrg, ownerToken } = await initSeed().createOwner(); + const { organization, createProject } = await createOrg(); + const { project, target, createToken } = await createProject(ProjectType.Federation); + + const token = await createToken({ + targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.Settings], + }); + + await token.createCdnAccess(); + + let paginatedResult = await execute({ + document: TargetCDNAccessTokensQuery, + variables: { + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + first: 1, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + expect(paginatedResult.target!.cdnAccessTokens.edges).toHaveLength(1); + + const deleteResult = await execute({ + document: DeleteCDNAccessTokenMutation, + variables: { + input: { + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + cdnAccessTokenId: paginatedResult.target!.cdnAccessTokens.edges[0].node.id, + }, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + + expect(deleteResult.deleteCdnAccessToken.ok).toBeDefined(); + expect(deleteResult.deleteCdnAccessToken.error).toBeNull(); + expect(deleteResult.deleteCdnAccessToken.ok!.deletedCdnAccessTokenId).toEqual( + paginatedResult.target!.cdnAccessTokens.edges[0].node.id, + ); + + paginatedResult = await execute({ + document: TargetCDNAccessTokensQuery, + variables: { + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + first: 1, + }, + authToken: ownerToken, + }).then(r => r.expectNoGraphQLErrors()); + expect(paginatedResult.target!.cdnAccessTokens.edges).toHaveLength(0); + }); + + it('delete cdn access token without access', async () => { + const { createOrg } = await initSeed().createOwner(); + const { createProject, organization } = await createOrg(); + const { target, project, createToken } = await createProject(ProjectType.Federation); + const token = await createToken({ + targetScopes: [TargetAccessScope.RegistryRead, TargetAccessScope.Settings], + }); + + await token.createCdnAccess(); + + const paginatedResult = await execute({ + document: TargetCDNAccessTokensQuery, + variables: { + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + first: 1, + }, + authToken: token.secret, + }).then(r => r.expectNoGraphQLErrors()); + expect(paginatedResult.target!.cdnAccessTokens.edges).toHaveLength(1); + + const { ownerToken } = await initSeed().createOwner(); + const deleteResult = await execute({ + document: DeleteCDNAccessTokenMutation, + variables: { + input: { + selector: { + organization: organization.cleanId, + project: project.cleanId, + target: target.cleanId, + }, + cdnAccessTokenId: paginatedResult.target!.cdnAccessTokens.edges[0].node.id, + }, + }, + authToken: ownerToken, + }).then(r => r.expectGraphQLErrors()); + + expect(deleteResult).toMatchInlineSnapshot(` + [ + { + "locations": [ + { + "column": 3, + "line": 2, + }, + ], + "message": "No access (reason: "Missing target:settings permission")", + "path": [ + "deleteCdnAccessToken", + ], + }, + ] + `); + }); +}); diff --git a/integration-tests/tests/api/schema/publish.spec.ts b/integration-tests/tests/api/schema/publish.spec.ts index 559211476..c97246c06 100644 --- a/integration-tests/tests/api/schema/publish.spec.ts +++ b/integration-tests/tests/api/schema/publish.spec.ts @@ -587,7 +587,7 @@ test.concurrent('CDN data can not be fetched with an invalid access token', asyn expect(result.schemaPublish.__typename).toBe('SchemaPublishSuccess'); const cdn = await readWriteToken.createCdnAccess(); - const res = await fetch(cdn.url + '/sdl', { + const res = await fetch(cdn.cdnUrl + '/sdl', { method: 'GET', headers: { 'X-Hive-CDN-Key': 'i-like-turtles', @@ -620,12 +620,12 @@ test.concurrent('CDN data can be fetched with an valid access token', async () = expect(result.schemaPublish.__typename).toBe('SchemaPublishSuccess'); const cdn = await readWriteToken.createCdnAccess(); - const artifactUrl = cdn.url + '/sdl'; + const artifactUrl = cdn.cdnUrl + '/sdl'; const cdnResult = await fetch(artifactUrl, { method: 'GET', headers: { - 'X-Hive-CDN-Key': cdn.token, + 'X-Hive-CDN-Key': cdn.secretAccessToken, }, }); diff --git a/packages/services/api/src/modules/cdn/module.graphql.ts b/packages/services/api/src/modules/cdn/module.graphql.ts index 1a4cc3d53..987d26f06 100644 --- a/packages/services/api/src/modules/cdn/module.graphql.ts +++ b/packages/services/api/src/modules/cdn/module.graphql.ts @@ -2,7 +2,8 @@ import { gql } from 'graphql-modules'; export default gql` extend type Mutation { - createCdnToken(selector: TargetSelectorInput!): CdnTokenResult! + createCdnAccessToken(input: CreateCdnAccessTokenInput!): CdnAccessTokenCreateResult! + deleteCdnAccessToken(input: DeleteCdnAccessTokenInput!): DeleteCdnAccessTokenResult! } type CdnTokenResult { @@ -16,4 +17,77 @@ export default gql` """ isCDNEnabled: Boolean! } + + extend type Target { + """ + The URL for accessing this target's artifacts via the CDN. + """ + cdnUrl: String! + """ + A paginated connection of CDN tokens for accessing this target's artifacts. + """ + cdnAccessTokens(first: Int, after: String): TargetCdnAccessTokenConnection! + } + + type CdnAccessToken { + id: ID! + alias: String! + firstCharacters: String! + lastCharacters: String! + createdAt: DateTime! + } + + type TargetCdnAccessTokenConnection { + edges: [TargetCdnAccessTokenEdge!]! + pageInfo: PageInfo! + } + + type TargetCdnAccessTokenEdge { + node: CdnAccessToken! + cursor: String! + } + + input DeleteCdnAccessTokenInput { + selector: TargetSelectorInput! + cdnAccessTokenId: ID! + } + + """ + @oneOf + """ + type DeleteCdnAccessTokenResult { + ok: DeleteCdnAccessTokenOk + error: DeleteCdnAccessTokenError + } + + type DeleteCdnAccessTokenOk { + deletedCdnAccessTokenId: ID! + } + + type DeleteCdnAccessTokenError implements Error { + message: String! + } + + input CreateCdnAccessTokenInput { + selector: TargetSelectorInput! + alias: String! + } + + """ + @oneOf + """ + type CdnAccessTokenCreateResult { + ok: CdnAccessTokenCreateOk + error: CdnAccessTokenCreateError + } + + type CdnAccessTokenCreateOk { + createdCdnAccessToken: CdnAccessToken! + secretAccessToken: String! + cdnUrl: String! + } + + type CdnAccessTokenCreateError implements Error { + message: String! + } `; diff --git a/packages/services/api/src/modules/cdn/providers/cdn.provider.ts b/packages/services/api/src/modules/cdn/providers/cdn.provider.ts index 5c1f2507c..d0b21e0c8 100644 --- a/packages/services/api/src/modules/cdn/providers/cdn.provider.ts +++ b/packages/services/api/src/modules/cdn/providers/cdn.provider.ts @@ -1,9 +1,11 @@ -import { createHmac } from 'crypto'; import bcryptjs from 'bcryptjs'; import { Inject, Injectable, Scope } from 'graphql-modules'; +import { z } from 'zod'; +import { encodeCdnToken, generatePrivateKey } from '@hive/cdn-script/cdn-token'; import type { Span } from '@sentry/types'; import { crypto } from '@whatwg-node/fetch'; import { HiveError } from '../../../shared/errors'; +import { isUUID } from '../../../shared/is-uuid'; import { sentry } from '../../../shared/sentry'; import { AuthManager } from '../../auth/providers/auth-manager'; import { TargetAccessScope } from '../../auth/providers/scopes'; @@ -15,14 +17,14 @@ import { CDN_CONFIG, type CDNConfig } from './tokens'; type CdnResourceType = 'schema' | 'supergraph' | 'metadata'; +const s3KeyPrefix = 'cdn-keys'; + @Injectable({ scope: Scope.Operation, global: true, }) export class CdnProvider { private logger: Logger; - private encoder: TextEncoder; - private secretKeyData: Uint8Array; constructor( logger: Logger, @@ -33,8 +35,6 @@ export class CdnProvider { @Inject(Storage) private storage: Storage, ) { this.logger = logger.child({ source: 'CdnProvider' }); - this.encoder = new TextEncoder(); - this.secretKeyData = this.encoder.encode(this.config.authPrivateKey); } isEnabled(): boolean { @@ -52,51 +52,6 @@ export class CdnProvider { throw new HiveError(`CDN is not configured, cannot resolve CDN target url.`); } - async generateCdnAccess(args: { organizationId: string; projectId: string; targetId: string }) { - await this.authManager.ensureTargetAccess({ - organization: args.organizationId, - project: args.projectId, - target: args.targetId, - scope: TargetAccessScope.REGISTRY_READ, - }); - - const token = this.legacy_generateToken(args.targetId); - const tokenHash = await bcryptjs.hash(token, await bcryptjs.genSalt()); - const url = this.getCdnUrlForTarget(args.targetId); - - const s3Key = `cdn-legacy-keys/${args.targetId}`; - - const s3Url = [this.s3Config.endpoint, this.s3Config.bucket, s3Key].join('/'); - const response = await this.s3Config.client.fetch(s3Url, { - method: 'PUT', - body: tokenHash, - }); - - if (response.status !== 200) { - throw new Error(`Unexpected Status for storing key. (status=${response.status})`); - } - - await this.storage.createCDNAccessToken({ - id: crypto.randomUUID(), - targetId: args.targetId, - s3Key, - firstCharacters: token.substring(0, 3), - lastCharacters: token.substring(token.length - 3), - alias: 'CDN Access Token', - }); - - return { - token, - url, - }; - } - - private legacy_generateToken(targetId: string): string { - return createHmac('sha256', this.secretKeyData) - .update(this.encoder.encode(targetId)) - .digest('base64'); - } - async pushToCloudflareCDN(url: string, body: string, span?: Span): Promise<{ success: boolean }> { if (this.config.providers.cloudflare === null) { this.logger.info(`Trying to push to the CDN, but CDN is not configured, skipping`); @@ -161,4 +116,177 @@ export class CdnProvider { result, ); } + + async createCDNAccessToken(args: { + organizationId: string; + projectId: string; + targetId: string; + alias: string; + }) { + const alias = AliasStringModel.safeParse(args.alias); + + if (alias.success === false) { + return { + type: 'failure', + reason: alias.error.issues[0].message, + } as const; + } + + await this.authManager.ensureTargetAccess({ + organization: args.organizationId, + project: args.projectId, + target: args.targetId, + scope: TargetAccessScope.READ, + }); + + // generate all things upfront so we do net get surprised by encoding issues after writing to the destination. + const keyId = crypto.randomUUID(); + const s3Key = `${s3KeyPrefix}/${args.targetId}/${keyId}`; + const privateKey = generatePrivateKey(); + const privateKeyHash = await bcryptjs.hash(privateKey, await bcryptjs.genSalt()); + const cdnAccessToken = encodeCdnToken({ keyId, privateKey }); + + // Check if key already exists + const headResponse = await this.s3Config.client.fetch( + [this.s3Config.endpoint, this.s3Config.bucket, s3Key].join('/'), + { + method: 'HEAD', + }, + ); + + if (headResponse.status !== 404) { + return { + type: 'failure', + reason: 'Failed to generate key. Please try again later.', + } as const; + } + + // put key onto s3 bucket + const putResponse = await this.s3Config.client.fetch( + [this.s3Config.endpoint, this.s3Config.bucket, s3Key].join('/'), + { + method: 'PUT', + body: privateKeyHash, + }, + ); + + if (putResponse.status !== 200) { + return { + type: 'failure', + reason: 'Failed to generate key. Please try again later. 2', + } as const; + } + + const cdnAccessTokenRecord = await this.storage.createCDNAccessToken({ + id: keyId, + targetId: args.targetId, + firstCharacters: cdnAccessToken.substring(0, 5), + lastCharacters: cdnAccessToken.substring(cdnAccessToken.length - 5, cdnAccessToken.length), + s3Key, + alias: args.alias, + }); + + if (cdnAccessTokenRecord === null) { + return { + type: 'failure', + reason: 'Failed to generate key. Please try again later.', + } as const; + } + + return { + type: 'success', + cdnAccessToken: cdnAccessTokenRecord, + secretAccessToken: cdnAccessToken, + } as const; + } + + public async deleteCDNAccessToken(args: { + organizationId: string; + projectId: string; + targetId: string; + cdnAccessTokenId: string; + }) { + await this.authManager.ensureTargetAccess({ + organization: args.organizationId, + project: args.projectId, + target: args.targetId, + scope: TargetAccessScope.SETTINGS, + }); + + if (isUUID(args.cdnAccessTokenId) === false) { + return { + type: 'failure', + reason: 'The CDN Access Token does not exist.', + } as const; + } + + // TODO: this should probably happen within a db transaction to ensure integrity + const record = await this.storage.getCDNAccessTokenById({ + cdnAccessTokenId: args.cdnAccessTokenId, + }); + + if (record === null || record.targetId !== args.targetId) { + return { + type: 'failure', + reason: 'The CDN Access Token does not exist.', + } as const; + } + + const headResponse = await this.s3Config.client.fetch( + [this.s3Config.endpoint, this.s3Config.bucket, record.s3Key].join('/'), + { + method: 'DELETE', + }, + ); + + if (headResponse.status !== 204) { + return { + type: 'failure', + reason: 'Failed deleting CDN Access Token. Please try again later.', + } as const; + } + + await this.storage.deleteCDNAccessToken({ + cdnAccessTokenId: args.cdnAccessTokenId, + }); + + if (record === null) { + return { + type: 'failure', + reason: 'The CDN Access Token. Does not exist.', + } as const; + } + + return { + type: 'success', + } as const; + } + + public async getPaginatedCDNAccessTokensForTarget(args: { + organizationId: string; + projectId: string; + targetId: string; + first: number | null; + cursor: string | null; + }) { + await this.authManager.ensureTargetAccess({ + organization: args.organizationId, + project: args.projectId, + target: args.targetId, + scope: TargetAccessScope.SETTINGS, + }); + + const paginatedResult = await this.storage.getPaginatedCDNAccessTokensForTarget({ + targetId: args.targetId, + first: args.first, + cursor: args.cursor, + }); + + return paginatedResult; + } } + +const AliasStringModel = z + .string() + .min(3, 'Must be at least 3 characters long.') + .max(100, 'Can not be longer than 100 characters.'); diff --git a/packages/services/api/src/modules/cdn/resolvers.ts b/packages/services/api/src/modules/cdn/resolvers.ts index 678960dc9..08f2f19d4 100644 --- a/packages/services/api/src/modules/cdn/resolvers.ts +++ b/packages/services/api/src/modules/cdn/resolvers.ts @@ -5,7 +5,7 @@ import { CdnProvider } from './providers/cdn.provider'; export const resolvers: CdnModule.Resolvers = { Mutation: { - createCdnToken: async (_, { selector }, { injector }) => { + createCdnAccessToken: async (_, { input }, { injector }) => { const translator = injector.get(IdTranslator); const cdn = injector.get(CdnProvider); @@ -14,16 +14,63 @@ export const resolvers: CdnModule.Resolvers = { } const [organizationId, projectId, targetId] = await Promise.all([ - translator.translateOrganizationId(selector), - translator.translateProjectId(selector), - translator.translateTargetId(selector), + translator.translateOrganizationId(input.selector), + translator.translateProjectId(input.selector), + translator.translateTargetId(input.selector), ]); - return await cdn.generateCdnAccess({ + const result = await cdn.createCDNAccessToken({ organizationId, projectId, targetId, + alias: input.alias, }); + + if (result.type === 'failure') { + return { + error: { + message: result.reason, + }, + }; + } + + return { + ok: { + secretAccessToken: result.secretAccessToken, + createdCdnAccessToken: result.cdnAccessToken, + cdnUrl: cdn.getCdnUrlForTarget(targetId), + }, + }; + }, + deleteCdnAccessToken: async (_, { input }, { injector }) => { + const translator = injector.get(IdTranslator); + + const [organizationId, projectId, targetId] = await Promise.all([ + translator.translateOrganizationId(input.selector), + translator.translateProjectId(input.selector), + translator.translateTargetId(input.selector), + ]); + + const deleteResult = await injector.get(CdnProvider).deleteCDNAccessToken({ + organizationId, + projectId, + targetId, + cdnAccessTokenId: input.cdnAccessTokenId, + }); + + if (deleteResult.type === 'failure') { + return { + error: { + message: deleteResult.reason, + }, + }; + } + + return { + ok: { + deletedCdnAccessTokenId: input.cdnAccessTokenId, + }, + }; }, }, Query: { @@ -33,4 +80,23 @@ export const resolvers: CdnModule.Resolvers = { return cdn.isEnabled(); }, }, + Target: { + async cdnAccessTokens(target, args, context) { + const result = await context.injector.get(CdnProvider).getPaginatedCDNAccessTokensForTarget({ + targetId: target.id, + projectId: target.projectId, + organizationId: target.orgId, + first: args.first ?? null, + cursor: args.after ?? null, + }); + + return { + edges: result.items, + pageInfo: result.pageInfo, + }; + }, + cdnUrl(target, _args, context) { + return context.injector.get(CdnProvider).getCdnUrlForTarget(target.id); + }, + }, }; diff --git a/packages/services/api/src/modules/shared/providers/storage.ts b/packages/services/api/src/modules/shared/providers/storage.ts index a9e4e1521..fe9d0d3f6 100644 --- a/packages/services/api/src/modules/shared/providers/storage.ts +++ b/packages/services/api/src/modules/shared/providers/storage.ts @@ -465,6 +465,29 @@ export interface Storage { lastCharacters: string; alias: string; }): Promise; + + getCDNAccessTokenById(_: { cdnAccessTokenId: string }): Promise; + + deleteCDNAccessToken(_: { cdnAccessTokenId: string }): Promise; + + getPaginatedCDNAccessTokensForTarget(_: { + targetId: string; + first: number | null; + cursor: null | string; + }): Promise< + Readonly<{ + items: ReadonlyArray<{ + node: CDNAccessToken; + cursor: string; + }>; + pageInfo: Readonly<{ + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }>; + }> + >; } @Injectable() diff --git a/packages/services/api/src/shared/is-uuid.ts b/packages/services/api/src/shared/is-uuid.ts new file mode 100644 index 000000000..41c21e932 --- /dev/null +++ b/packages/services/api/src/shared/is-uuid.ts @@ -0,0 +1,6 @@ +/** + * Utility for checking whether a string value is a valid UUID that can be forwarded to Postgres. + */ +export function isUUID(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} diff --git a/packages/services/api/tsconfig.json b/packages/services/api/tsconfig.json index 9b376c2b1..bb31dec94 100644 --- a/packages/services/api/tsconfig.json +++ b/packages/services/api/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../../tsconfig.json", - "include": ["src"] + "include": ["src", "../cdn-worker/src/cdn-token.ts"] } diff --git a/packages/services/cdn-worker/package.json b/packages/services/cdn-worker/package.json index b29255087..386cd1214 100644 --- a/packages/services/cdn-worker/package.json +++ b/packages/services/cdn-worker/package.json @@ -1,6 +1,7 @@ { "name": "@hive/cdn-script", "version": "0.0.0", + "type": "module", "license": "MIT", "private": true, "scripts": { diff --git a/packages/services/cdn-worker/src/analytics.ts b/packages/services/cdn-worker/src/analytics.ts index b26b59ba0..483ce2be9 100644 --- a/packages/services/cdn-worker/src/analytics.ts +++ b/packages/services/cdn-worker/src/analytics.ts @@ -19,25 +19,30 @@ type Event = | 'sdl.graphqls'; } | { - type: 'new-key-validation'; + type: 'key-validation'; value: | { type: 'cache-hit'; + version: 'v1' | 'legacy'; isValid: boolean; } | { type: 'cache-write'; + version: 'v1' | 'legacy'; isValid: boolean; } | { type: 's3-key-read-failure'; + version: 'v1' | 'legacy'; status: number | null; } | { type: 's3-key-compare-failure'; + version: 'v1' | 'legacy'; } | { - type: 's3-key-validation-success'; + type: 's3-key-validation'; + version: 'v1' | 'legacy'; status: 'success' | 'failure'; }; } @@ -69,21 +74,29 @@ export function createAnalytics( return engines.error.writeDataPoint({ blobs: event.value, }); - case 'new-key-validation': + case 'key-validation': switch (event.value.type) { case 'cache-hit': return engines.keyValidation.writeDataPoint({ - blobs: ['cache-hit', event.value.isValid ? 'valid' : 'invalid'], + blobs: [ + 'cache-hit', + event.value.version, + event.value.isValid ? 'valid' : 'invalid', + ], indexes: [targetId.substr(0, 32)], }); case 'cache-write': return engines.keyValidation.writeDataPoint({ - blobs: ['cache-write', event.value.isValid ? 'valid' : 'invalid'], + blobs: [ + 'cache-write', + event.value.version, + event.value.isValid ? 'valid' : 'invalid', + ], indexes: [targetId.substr(0, 32)], }); - case 's3-key-validation-success': + case 's3-key-validation': return engines.keyValidation.writeDataPoint({ - blobs: ['s3-key-validation-success', event.value.status], + blobs: ['s3-key-validation', event.value.version, event.value.status], indexes: [targetId.substr(0, 32)], }); } diff --git a/packages/services/cdn-worker/src/cdn-token.ts b/packages/services/cdn-worker/src/cdn-token.ts new file mode 100644 index 000000000..9e21d94d5 --- /dev/null +++ b/packages/services/cdn-worker/src/cdn-token.ts @@ -0,0 +1,64 @@ +/** + * Note: This file needs to run both on Node.js and Cloudflare Workers. + */ +import * as bc from 'bcryptjs'; +import { crypto } from '@whatwg-node/fetch'; + +export interface CDNToken { + keyId: string; + privateKey: string; +} + +/** + * We prefix the token so we can check fast wether a token is a new one or a legacy one. + */ +const keyPrefix = 'hv2'; + +/** + * Encode a CDN token into a hex string with prefix. + */ +export function encodeCdnToken(args: CDNToken): string { + const keyContents = [args.keyId, args.privateKey].join(':'); + return keyPrefix + globalThis.btoa(keyContents); +} + +export function isCDNAccessToken(token: string) { + return token.startsWith(keyPrefix) === true; +} + +const decodeError = { type: 'failure', reason: 'Invalid access token.' } as const; + +/** + * Safely decode a CDN token that got serialized as a string. + */ +export function decodeCdnAccessTokenSafe(token: string) { + if (isCDNAccessToken(token) === false) { + return decodeError; + } + + token = token.slice(keyPrefix.length); + + const str = globalThis.atob(token); + const [keyId, privateKey] = str.split(':'); + if (keyId && privateKey) { + return { type: 'success', token: { keyId, privateKey } } as const; + } + return decodeError; +} + +/** + * Verify whether a CDN token is valid. + */ +export async function verifyCdnToken(privateKey: string, privateKeyHash: string) { + return bc.compare(privateKey, privateKeyHash); +} + +export function generatePrivateKey(): string { + const array = new Uint32Array(5); + crypto.getRandomValues(array); + return Array.from(array, dec2hex).join(''); +} + +function dec2hex(dec: number) { + return dec.toString(16).padStart(2, '0'); +} diff --git a/packages/services/cdn-worker/src/key-validation.ts b/packages/services/cdn-worker/src/key-validation.ts index 19bbefaba..3ae6e290e 100644 --- a/packages/services/cdn-worker/src/key-validation.ts +++ b/packages/services/cdn-worker/src/key-validation.ts @@ -1,16 +1,8 @@ import bcrypt from 'bcryptjs'; +import { Request, Response } from '@whatwg-node/fetch'; import { Analytics } from './analytics'; -import { AwsClient } from './aws'; - -export function byteStringToUint8Array(byteString: string) { - const ui = new Uint8Array(byteString.length); - - for (let i = 0; i < byteString.length; ++i) { - ui[i] = byteString.charCodeAt(i); - } - - return ui; -} +import { type AwsClient } from './aws'; +import { decodeCdnAccessTokenSafe, isCDNAccessToken } from './cdn-token'; export type KeyValidator = (targetId: string, headerKey: string) => Promise; @@ -34,14 +26,18 @@ type CreateKeyValidatorDeps = { export const createIsKeyValid = (deps: CreateKeyValidatorDeps): KeyValidator => async (targetId: string, accessHeaderValue: string): Promise => { - return validateKey({ + if (isCDNAccessToken(accessHeaderValue)) { + return handleCDNAccessToken(deps, targetId, accessHeaderValue); + } + + return handleLegacyCDNAccessToken({ ...deps, targetId, accessToken: accessHeaderValue, }); }; -const validateKey = async (args: { +const handleLegacyCDNAccessToken = async (args: { targetId: string; accessToken: string; s3: S3Config; @@ -76,9 +72,10 @@ const validateKey = async (args: { args.analytics?.track( { - type: 'new-key-validation', + type: 'key-validation', value: { type: 'cache-hit', + version: 'legacy', isValid, }, }, @@ -91,9 +88,10 @@ const validateKey = async (args: { withCache = async (isValid: boolean) => { args.analytics?.track( { - type: 'new-key-validation', + type: 'key-validation', value: { type: 'cache-write', + version: 'legacy', isValid, }, }, @@ -136,9 +134,10 @@ const validateKey = async (args: { args.analytics?.track( { - type: 'new-key-validation', + type: 'key-validation', value: { - type: 's3-key-validation-success', + type: 's3-key-validation', + version: 'legacy', status: isValid ? 'success' : 'failure', }, }, @@ -147,3 +146,115 @@ const validateKey = async (args: { return withCache(isValid); }; + +async function handleCDNAccessToken( + deps: CreateKeyValidatorDeps, + targetId: string, + accessToken: string, +) { + let withCache = (isValid: boolean) => Promise.resolve(isValid); + + { + const requestCache = await deps.getCache?.(); + + if (requestCache) { + const cacheKey = new Request( + ['http://key-cache.graphql-hive.com', 'v1', targetId, encodeURIComponent(accessToken)].join( + '/', + ), + { + method: 'GET', + }, + ); + + const response = await requestCache.match(cacheKey); + + if (response) { + const responseValue = await response.text(); + + const isValid = responseValue === '1'; + + deps.analytics?.track( + { + type: 'key-validation', + value: { + type: 'cache-hit', + version: 'v1', + isValid, + }, + }, + targetId, + ); + + return isValid; + } + + withCache = async (isValid: boolean) => { + deps.analytics?.track( + { + type: 'key-validation', + value: { + type: 'cache-write', + version: 'v1', + isValid, + }, + }, + targetId, + ); + + const promise = requestCache.put( + cacheKey, + new Response(isValid ? '1' : '0', { + status: 200, + headers: { + 'Cache-Control': `s-maxage=${60 * 5}`, + }, + }), + ); + + if (deps.waitUntil) { + deps.waitUntil(promise); + } else { + await promise; + } + + return isValid; + }; + } + } + + const decodeResult = decodeCdnAccessTokenSafe(accessToken); + + if (decodeResult.type === 'failure') { + return withCache(false); + } + + const s3KeyParts = ['cdn-keys', targetId, decodeResult.token.keyId]; + + const key = await deps.s3.client.fetch( + [deps.s3.endpoint, deps.s3.bucketName, ...s3KeyParts].join('/'), + { + method: 'GET', + }, + ); + + if (key.status !== 200) { + return withCache(false); + } + + const isValid = await bcrypt.compare(decodeResult.token.privateKey, await key.text()); + + deps.analytics?.track( + { + type: 'key-validation', + value: { + type: 's3-key-validation', + version: 'v1', + status: isValid ? 'success' : 'failure', + }, + }, + targetId, + ); + + return withCache(isValid); +} diff --git a/packages/services/storage/src/index.ts b/packages/services/storage/src/index.ts index 701f51478..096bdf4dc 100644 --- a/packages/services/storage/src/index.ts +++ b/packages/services/storage/src/index.ts @@ -2741,7 +2741,7 @@ export async function createStorage(connection: string, maximumPoolSize: number) , "first_characters" , "last_characters" , "alias" - , "created_at" + , to_json("created_at") as "created_at" `); if (result === null) { @@ -2750,11 +2750,142 @@ export async function createStorage(connection: string, maximumPoolSize: number) return decodeCDNAccessTokenRecord(result); }, + + async getCDNAccessTokenById(args) { + const result = await pool.maybeOne(sql` + SELECT + "id" + , "target_id" + , "s3_key" + , "first_characters" + , "last_characters" + , "alias" + , to_json("created_at") as "created_at" + FROM + "public"."cdn_access_tokens" + WHERE + "id" = ${args.cdnAccessTokenId} + `); + + if (result == null) { + return null; + } + return decodeCDNAccessTokenRecord(result); + }, + + async deleteCDNAccessToken(args) { + const result = await pool.maybeOne(sql` + DELETE + FROM + "public"."cdn_access_tokens" + WHERE + "id" = ${args.cdnAccessTokenId} + RETURNING + "id" + `); + + return result != null; + }, + + async getPaginatedCDNAccessTokensForTarget(args) { + let cursor: null | { + createdAt: string; + id: string; + } = null; + + const limit = args.first ? (args.first > 0 ? Math.min(args.first, 20) : 20) : 20; + + if (args.cursor) { + cursor = decodeCDNAccessTokenCursor(args.cursor); + } + + const result = await pool.any(sql` + SELECT + "id" + , "target_id" + , "s3_key" + , "first_characters" + , "last_characters" + , "alias" + , to_json("created_at") as "created_at" + FROM + "public"."cdn_access_tokens" + WHERE + "target_id" = ${args.targetId} + ${ + cursor + ? sql` + AND ( + ( + "cdn_access_tokens"."created_at" = ${cursor.createdAt} + AND "id" < ${cursor.id} + ) + OR "cdn_access_tokens"."created_at" < ${cursor.createdAt} + ) + ` + : sql`` + } + ORDER BY + "target_id" ASC + , "cdn_access_tokens"."created_at" DESC + , "id" DESC + LIMIT ${limit + 1} + `); + + let items = result.map(row => { + const node = decodeCDNAccessTokenRecord(row); + + return { + node, + get cursor() { + return encodeCDNAccessTokenCursor(node); + }, + }; + }); + + const hasNextPage = items.length > limit; + + items = items.slice(0, limit); + + return { + items, + pageInfo: { + hasNextPage, + hasPreviousPage: cursor !== null, + get endCursor() { + return items[items.length - 1]?.cursor ?? ''; + }, + get startCursor() { + return items[0]?.cursor ?? ''; + }, + }, + }; + }, }; return storage; } +function encodeCDNAccessTokenCursor(cursor: { createdAt: string; id: string }) { + return Buffer.from(`${cursor.createdAt}|${cursor.id}`).toString('base64'); +} + +function decodeCDNAccessTokenCursor(cursor: string) { + const [createdAt, id] = Buffer.from(cursor, 'base64').toString('utf8').split('|'); + if ( + Number.isNaN(Date.parse(createdAt)) || + id === undefined || + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) === false + ) { + throw new Error('Invalid cursor'); + } + + return { + createdAt, + id, + }; +} + function isDefined(val: T | undefined | null): val is T { return val !== undefined && val !== null; } @@ -2818,7 +2949,7 @@ const CDNAccessTokenModel = zod.object({ first_characters: zod.string(), last_characters: zod.string(), alias: zod.string(), - created_at: zod.number(), + created_at: zod.string(), }); const decodeCDNAccessTokenRecord = (result: unknown): CDNAccessToken => { @@ -2830,8 +2961,8 @@ const decodeCDNAccessTokenRecord = (result: unknown): CDNAccessToken => { s3Key: rawRecord.s3_key, firstCharacters: rawRecord.first_characters, lastCharacters: rawRecord.last_characters, - createdAt: new Date(rawRecord.created_at).toString(), alias: rawRecord.alias, + createdAt: rawRecord.created_at, }; }; diff --git a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx index 36fbc8e0f..c834c9e53 100644 --- a/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx +++ b/packages/web/app/pages/[orgId]/[projectId]/[targetId]/settings.tsx @@ -7,6 +7,7 @@ import * as Yup from 'yup'; import { authenticated } from '@/components/authenticated-container'; import { TargetLayout } from '@/components/layouts'; import { SchemaEditor } from '@/components/schema-editor'; +import { CDNAccessTokens } from '@/components/target/settings/cdn-access-tokens'; import { Button, Card, @@ -43,7 +44,7 @@ const columns = [ { key: 'createdAt', align: 'right' }, ] as const; -const Tokens = ({ me }: { me: MemberFieldsFragment }): ReactElement => { +const RegistryAccessTokens = ({ me }: { me: MemberFieldsFragment }): ReactElement => { const router = useRouteSelector(); const [{ fetching: deleting }, mutate] = useMutation(DeleteTokensDocument); const [checked, setChecked] = useState([]); @@ -78,7 +79,7 @@ const Tokens = ({ me }: { me: MemberFieldsFragment }): ReactElement => { return ( - Tokens + Registry Access Tokens

Be careful! These tokens allow to read and write your target data.

@@ -660,7 +661,9 @@ const Page = ({ )}
- {canAccessTokens && } + {canAccessTokens && } + + {canAccessTokens && } diff --git a/packages/web/app/src/components/target/settings/cdn-access-tokens.tsx b/packages/web/app/src/components/target/settings/cdn-access-tokens.tsx new file mode 100644 index 000000000..1214eac9e --- /dev/null +++ b/packages/web/app/src/components/target/settings/cdn-access-tokens.tsx @@ -0,0 +1,464 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useFormik } from 'formik'; +import { gql, useMutation, useQuery } from 'urql'; +import * as Yup from 'yup'; +import { Button, Card, Heading, Input, Modal, Table, Tag, TimeAgo } from '@/components/v2'; +import { AlertTriangleIcon, TrashIcon } from '@/components/v2/icon'; +import { InlineCode } from '@/components/v2/inline-code'; +import { TargetAccessScope } from '@/gql/graphql'; +import { MemberFieldsFragment } from '@/graphql'; +import { canAccessTarget } from '@/lib/access/target'; +import { useRouteSelector } from '@/lib/hooks'; + +// Note: this will be used in the future :) +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const CDNAccessTokeRowFragment = gql(/* GraphQL */ ` + fragment CDNAccessTokens_CdnAccessTokenRowFragment on CdnAccessToken { + id + firstCharacters + lastCharacters + alias + createdAt + } +`); + +const CDNAccessTokenCreateMutation = gql(/* GraphQL */ ` + mutation CDNAccessTokens_CDNAccessTokenCreateMutation($input: CreateCdnAccessTokenInput!) { + createCdnAccessToken(input: $input) { + error { + message + } + ok { + createdCdnAccessToken { + ...CDNAccessTokens_CdnAccessTokenRowFragment + } + secretAccessToken + } + } + } +`); + +const CreateCDNAccessTokenModal = (props: { + onCreateCDNAccessToken: () => void; + onClose: () => void; +}) => { + const router = useRouteSelector(); + const [createCdnAccessToken, mutate] = useMutation(CDNAccessTokenCreateMutation); + + const form = useFormik({ + enableReinitialize: true, + initialValues: { + alias: '', + }, + validationSchema: Yup.object().shape({ + alias: Yup.string().required('Please enter an alias').min(3).max(100), + }), + onSubmit: async values => { + await mutate({ + input: { + selector: { + organization: router.organizationId, + project: router.projectId, + target: router.targetId, + }, + alias: values.alias, + }, + }); + }, + }); + + useEffect(() => { + if (createCdnAccessToken.data?.createCdnAccessToken.ok?.createdCdnAccessToken.id) { + props.onCreateCDNAccessToken(); + } + }, [createCdnAccessToken.data?.createCdnAccessToken.ok?.createdCdnAccessToken.id]); + + let body = ( +
+
+ Create CDN Access Token +
+ +
+ + + {form.touched.alias && form.errors.alias ? ( + {form.errors.alias} + ) : null} +
+ +
+ + + +
+
+ ); + + if (createCdnAccessToken.data?.createCdnAccessToken.ok) { + body = ( +
+
+ Create CDN Access Token +
+ +

The CDN Access Token was successfully created.

+ + + + Please store this access token securely. You will not be able to see it again. + + + + +
+ +
+
+ ); + } else if (createCdnAccessToken.data?.createCdnAccessToken.error) { + body = ( +
+
+ Delete CDN Access Token +
+ +

Something went wrong.

+ + + + {createCdnAccessToken.data?.createCdnAccessToken.error.message} + + + +
+ ); + } + + return ( + + {body} + + ); +}; + +const CDNAccessTokenDeleteMutation = gql(/* GraphQL */ ` + mutation CDNAccessTokens_DeleteCDNAccessToken($input: DeleteCdnAccessTokenInput!) { + deleteCdnAccessToken(input: $input) { + error { + message + } + ok { + deletedCdnAccessTokenId + } + } + } +`); + +const DeleteCDNAccessTokenModal = (props: { + cdnAccessTokenId: string; + onDeletedAccessTokenId: (deletedAccessTokenId: string) => void; + onClose: () => void; +}) => { + const router = useRouteSelector(); + const [deleteCdnAccessToken, mutate] = useMutation(CDNAccessTokenDeleteMutation); + + useEffect(() => { + if (deleteCdnAccessToken.data?.deleteCdnAccessToken.ok?.deletedCdnAccessTokenId) { + props.onDeletedAccessTokenId( + deleteCdnAccessToken.data.deleteCdnAccessToken.ok.deletedCdnAccessTokenId, + ); + } + }, [deleteCdnAccessToken.data?.deleteCdnAccessToken.ok?.deletedCdnAccessTokenId ?? null]); + + const onClose = () => props.onClose(); + + let body = ( +
+
+ Delete CDN Access Tokens +
+ + + Deleting an CDN access token can not be undone. After deleting the access token it might + take up to 5 minutes before the changes are propagated across the CDN. + +

Are you sure you want to delete the CDN Access Token?

+ +
+ + +
+
+ ); + + if (deleteCdnAccessToken.data?.deleteCdnAccessToken.ok) { + body = ( +
+
+ Delete CDN Access Token +
+ +

The CDN Access Token was successfully deleted.

+ + + + It can take up to 5 minutes before the changes are propagated across the CDN. + +
+ +
+
+ ); + } else if (deleteCdnAccessToken.data?.deleteCdnAccessToken.error) { + body = ( +
+
+ Delete CDN Access Token +
+ +

Something went wrong.

+ + + + {deleteCdnAccessToken.data?.deleteCdnAccessToken.error.message} + +
+ +
+
+ ); + } + + return ( + + {body} + + ); +}; + +const CDNAccessTokensQuery = gql(/* GraphQL */ ` + query CDNAccessTokensQuery($selector: TargetSelectorInput!, $first: Int!, $after: String) { + target(selector: $selector) { + id + cdnAccessTokens(first: $first, after: $after) { + edges { + node { + id + ...CDNAccessTokens_CdnAccessTokenRowFragment + } + } + pageInfo { + hasNextPage + hasPreviousPage + endCursor + } + } + } + } +`); + +const isDeleteCDNAccessTokenModalPath = (path: string): null | string => { + const pattern = /#delete-cdn-access-token\?id=(([a-zA-Z-\d]*))$/; + + const result = path.match(pattern); + if (result === null) { + return null; + } + + return result[1]; +}; + +export const CDNAccessTokens = (props: { me: MemberFieldsFragment }): React.ReactElement => { + const routerSelector = useRouteSelector(); + const router = useRouter(); + + const [endCursors, setEndCursors] = useState>([]); + + const openCreateCDNAccessTokensModalLink = `${router.asPath}#create-cdn-access-token`; + const isCreateCDNAccessTokensModalOpen = router.asPath.endsWith('#create-cdn-access-token'); + + const deleteCDNAccessTokenId = useMemo(() => { + return isDeleteCDNAccessTokenModalPath(router.asPath); + }, [router.asPath]); + + const closeModal = () => { + void router.push(router.asPath.split('#')[0], undefined, { + scroll: false, + }); + }; + + const [target, reexecuteQuery] = useQuery({ + query: CDNAccessTokensQuery, + variables: { + selector: { + organization: routerSelector.organizationId, + project: routerSelector.projectId, + target: routerSelector.targetId, + }, + first: 10, + after: endCursors[endCursors.length - 1] ?? null, + }, + requestPolicy: 'cache-and-network', + }); + + const canManage = canAccessTarget(TargetAccessScope.Settings, props.me); + + return ( + + + CDN Access Token + +

+ Be careful! These tokens allow accessing the schema artifacts of your target. +

+ {canManage && ( +
+ +
+ )} + ({ + id: edge.node.id, + name: + edge.node.firstCharacters + new Array(10).fill('•').join('') + edge.node.lastCharacters, + alias: edge.node.alias, + createdAt: ( + <> + created + + ), + delete: ( + + ), + }))} + columns={[ + { key: 'name' }, + { key: 'alias' }, + { key: 'createdAt', align: 'right' }, + { key: 'delete', align: 'right' }, + ]} + /> +
+ {target.data?.target?.cdnAccessTokens.pageInfo.hasPreviousPage ? ( + + ) : null} + +
+ {isCreateCDNAccessTokensModalOpen ? ( + { + reexecuteQuery({ requestPolicy: 'network-only' }); + }} + onClose={closeModal} + /> + ) : null} + {deleteCDNAccessTokenId ? ( + { + reexecuteQuery({ requestPolicy: 'network-only' }); + }} + onClose={closeModal} + /> + ) : null} + + ); +}; diff --git a/packages/web/app/src/components/v2/heading.tsx b/packages/web/app/src/components/v2/heading.tsx index d05fe9bdd..1b43c1fb5 100644 --- a/packages/web/app/src/components/v2/heading.tsx +++ b/packages/web/app/src/components/v2/heading.tsx @@ -10,6 +10,7 @@ export const Heading = ({ children: ReactNode; size?: 'lg' | 'xl' | '2xl'; className?: string; + id?: string; }): ReactElement => { const HeadingLevel = size === '2xl' ? 'h1' : 'h3'; diff --git a/packages/web/app/src/components/v2/modals/connect-schema.tsx b/packages/web/app/src/components/v2/modals/connect-schema.tsx index c7461f04c..0841b48be 100644 --- a/packages/web/app/src/components/v2/modals/connect-schema.tsx +++ b/packages/web/app/src/components/v2/modals/connect-schema.tsx @@ -1,15 +1,17 @@ -import { ReactElement, useEffect, useState } from 'react'; -import { useMutation, useQuery } from 'urql'; +import { ReactElement } from 'react'; +import { gql, useQuery } from 'urql'; import { Button, CopyValue, Heading, Link, Modal, Tag } from '@/components/v2'; -import { CreateCdnTokenDocument, ProjectDocument, ProjectType } from '@/graphql'; import { getDocsUrl } from '@/lib/docs-url'; import { useRouteSelector } from '@/lib/hooks'; -import { Spinner } from '@chakra-ui/react'; -const taxonomy = { - [ProjectType.Federation]: 'supergraph schema', - [ProjectType.Stitching]: 'services', -} as Record; +const ConnectSchemaModalQuery = gql(/* GraphQL */ ` + query ConnectSchemaModal($targetSelector: TargetSelectorInput!) { + target(selector: $targetSelector) { + id + cdnUrl + } + } +`); export const ConnectSchemaModal = ({ isOpen, @@ -18,68 +20,57 @@ export const ConnectSchemaModal = ({ isOpen: boolean; toggleModalOpen: () => void; }): ReactElement => { - const [generating, setGenerating] = useState(true); const router = useRouteSelector(); - const [projectQuery] = useQuery({ - query: ProjectDocument, + const [query] = useQuery({ + query: ConnectSchemaModalQuery, variables: { - organizationId: router.organizationId, - projectId: router.projectId, - }, - requestPolicy: 'cache-and-network', - }); - const [mutation, mutate] = useMutation(CreateCdnTokenDocument); - - useEffect(() => { - if (!isOpen) { - setGenerating(true); - return; - } - - void mutate({ - selector: { + targetSelector: { organization: router.organizationId, project: router.projectId, target: router.targetId, }, - }).then(() => { - setTimeout(() => { - setGenerating(false); - }, 2000); - }); - }, [isOpen, mutate, router.organizationId, router.projectId, router.targetId]); - - const project = projectQuery.data?.project; + }, + requestPolicy: 'cache-and-network', + }); return ( Connect to Hive - {project && generating && ( -
- - Generating access... -

- Hive is now generating an authentication token and an URL you can use to fetch your{' '} - {taxonomy[project.type] ?? 'schema'}. -

-
- )} - - {project && !generating && mutation.data && ( + {query.data?.target && ( <>

With high-availability and multi-zone CDN service based on Cloudflare, Hive allows you - to access the - {taxonomy[project.type] ?? 'schema'} of your API, through a secured external service, - that's always up regardless of Hive. + to access the schema of your API, through a secured external service, that's always up + regardless of Hive.

You can use the following endpoint: - + - To authenticate, use the following HTTP headers: + To authenticate, use the access HTTP headers.
- X-Hive-CDN-Key: {mutation.data.createCdnToken.token} +

+ + X-Hive-CDN-Key: {'<'}Your Access Token{'>'} + +

+

+ You can manage and generate CDN access tokens in the{' '} + + target settings + +

Read the{' '}