feat: support new cdn tokens (#1061)

This commit is contained in:
Laurin Quast 2023-01-27 12:59:09 +01:00 committed by GitHub
parent e9de7a534f
commit c8d6aa4a27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1635 additions and 257 deletions

View file

@ -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",

View file

@ -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,
},
});

View file

@ -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(),
);

View file

@ -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",
],
},
]
`);
});
});

View file

@ -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,
},
});

View file

@ -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!
}
`;

View file

@ -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.');

View file

@ -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);
},
},
};

View file

@ -465,6 +465,29 @@ export interface Storage {
lastCharacters: string;
alias: string;
}): Promise<CDNAccessToken | null>;
getCDNAccessTokenById(_: { cdnAccessTokenId: string }): Promise<CDNAccessToken | null>;
deleteCDNAccessToken(_: { cdnAccessTokenId: string }): Promise<boolean>;
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()

View file

@ -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);
}

View file

@ -1,4 +1,4 @@
{
"extends": "../../../tsconfig.json",
"include": ["src"]
"include": ["src", "../cdn-worker/src/cdn-token.ts"]
}

View file

@ -1,6 +1,7 @@
{
"name": "@hive/cdn-script",
"version": "0.0.0",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {

View file

@ -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)],
});
}

View file

@ -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');
}

View file

@ -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<boolean>;
@ -34,14 +26,18 @@ type CreateKeyValidatorDeps = {
export const createIsKeyValid =
(deps: CreateKeyValidatorDeps): KeyValidator =>
async (targetId: string, accessHeaderValue: string): Promise<boolean> => {
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);
}

View file

@ -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<T>(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,
};
};

View file

@ -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<string[]>([]);
@ -78,7 +79,7 @@ const Tokens = ({ me }: { me: MemberFieldsFragment }): ReactElement => {
return (
<Card>
<Heading className="mb-2">Tokens</Heading>
<Heading className="mb-2">Registry Access Tokens</Heading>
<p className="mb-3 font-light text-gray-300">
Be careful! These tokens allow to read and write your target data.
</p>
@ -660,7 +661,9 @@ const Page = ({
)}
</Card>
{canAccessTokens && <Tokens me={me} />}
{canAccessTokens && <RegistryAccessTokens me={me} />}
{canAccessTokens && <CDNAccessTokens me={me} />}
<ConditionalBreakingChanges />

View file

@ -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 = (
<form className="flex flex-1 flex-col items-stretch gap-12" onSubmit={form.handleSubmit}>
<div className="flex flex-col gap-5">
<Heading className="text-center">Create CDN Access Token</Heading>
</div>
<div className="flex flex-col gap-4">
<label className="text-sm font-semibold" htmlFor="buildUrl">
CDN Access Token Alias
</label>
<Input
placeholder="Alias"
name="alias"
value={form.values.alias}
onChange={form.handleChange}
onBlur={form.handleBlur}
disabled={form.isSubmitting}
isInvalid={form.touched.alias && !!form.errors.alias}
/>
{form.touched.alias && form.errors.alias ? (
<span className="text-sm text-red-500">{form.errors.alias}</span>
) : null}
</div>
<div className="mt-auto flex w-full gap-2 self-end">
<Button size="large" className="ml-auto" onClick={props.onClose}>
Abort
</Button>
<Button
type="submit"
variant="primary"
size="large"
disabled={createCdnAccessToken.fetching}
>
Create
</Button>
</div>
</form>
);
if (createCdnAccessToken.data?.createCdnAccessToken.ok) {
body = (
<div className="flex flex-1 flex-col items-stretch gap-12">
<div className="flex flex-col gap-5">
<Heading className="text-center">Create CDN Access Token</Heading>
</div>
<p>The CDN Access Token was successfully created.</p>
<Tag color="yellow" className="py-2.5 px-4">
<AlertTriangleIcon className="h-5 w-5" />
Please store this access token securely. You will not be able to see it again.
</Tag>
<InlineCode content={createCdnAccessToken.data.createCdnAccessToken.ok.secretAccessToken} />
<div className="mt-auto flex w-full gap-2 self-end">
<Button variant="primary" size="large" className="ml-auto" onClick={props.onClose}>
Close
</Button>
</div>
</div>
);
} else if (createCdnAccessToken.data?.createCdnAccessToken.error) {
body = (
<div className="flex flex-1 flex-col items-stretch gap-12">
<div className="flex flex-col gap-5">
<Heading className="text-center">Delete CDN Access Token</Heading>
</div>
<p>Something went wrong.</p>
<Tag color="yellow" className="py-2.5 px-4">
<AlertTriangleIcon className="h-5 w-5" />
{createCdnAccessToken.data?.createCdnAccessToken.error.message}
</Tag>
<Button variant="primary" size="large" className="ml-auto" onClick={props.onClose}>
Close
</Button>
</div>
);
}
return (
<Modal open={true} className="w-[650px]" onOpenChange={props.onClose}>
{body}
</Modal>
);
};
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 = (
<div className="flex flex-1 flex-col items-stretch gap-12">
<div className="flex flex-col gap-5">
<Heading className="text-center">Delete CDN Access Tokens</Heading>
</div>
<Tag color="yellow" className="py-2.5 px-4">
<AlertTriangleIcon className="h-5 w-5" />
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.
</Tag>
<p>Are you sure you want to delete the CDN Access Token?</p>
<div className="mt-auto flex w-full gap-2 self-end">
<Button variant="primary" size="large" className="ml-auto" onClick={onClose}>
Abort
</Button>
<Button
disabled={deleteCdnAccessToken.fetching}
danger
variant="primary"
size="large"
onClick={() =>
mutate({
input: {
selector: {
organization: router.organizationId,
project: router.projectId,
target: router.targetId,
},
cdnAccessTokenId: props.cdnAccessTokenId,
},
})
}
>
Delete
</Button>
</div>
</div>
);
if (deleteCdnAccessToken.data?.deleteCdnAccessToken.ok) {
body = (
<div className="flex flex-1 flex-col items-stretch gap-12">
<div className="flex flex-col gap-5">
<Heading className="text-center">Delete CDN Access Token</Heading>
</div>
<p>The CDN Access Token was successfully deleted.</p>
<Tag color="yellow" className="py-2.5 px-4">
<AlertTriangleIcon className="h-5 w-5" />
It can take up to 5 minutes before the changes are propagated across the CDN.
</Tag>
<div className="mt-auto flex w-full gap-2 self-end">
<Button variant="primary" size="large" className="ml-auto" onClick={onClose}>
Close
</Button>
</div>
</div>
);
} else if (deleteCdnAccessToken.data?.deleteCdnAccessToken.error) {
body = (
<div className="flex flex-1 flex-col items-stretch gap-12">
<div className="flex flex-col gap-5">
<Heading className="text-center">Delete CDN Access Token</Heading>
</div>
<p>Something went wrong.</p>
<Tag color="yellow" className="py-2.5 px-4">
<AlertTriangleIcon className="h-5 w-5" />
{deleteCdnAccessToken.data?.deleteCdnAccessToken.error.message}
</Tag>
<div className="mt-auto flex w-full gap-2 self-end">
<Button variant="primary" size="large" className="ml-auto" onClick={onClose}>
Close
</Button>
</div>
</div>
);
}
return (
<Modal open={true} className="w-[650px]" onOpenChange={onClose}>
{body}
</Modal>
);
};
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<Array<string>>([]);
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 (
<Card>
<Heading id="cdn-access-tokens" className="mb-2">
CDN Access Token
</Heading>
<p className="mb-3 font-light text-gray-300">
Be careful! These tokens allow accessing the schema artifacts of your target.
</p>
{canManage && (
<div className="my-3.5 flex justify-between">
<Button
as="a"
href={openCreateCDNAccessTokensModalLink}
variant="secondary"
onClick={ev => {
ev.preventDefault();
void router.push(openCreateCDNAccessTokensModalLink);
}}
size="large"
className="px-5"
>
Create new CDN Token
</Button>
</div>
)}
<Table
dataSource={target?.data?.target?.cdnAccessTokens.edges?.map(edge => ({
id: edge.node.id,
name:
edge.node.firstCharacters + new Array(10).fill('•').join('') + edge.node.lastCharacters,
alias: edge.node.alias,
createdAt: (
<>
created <TimeAgo date={edge.node.createdAt} />
</>
),
delete: (
<Button
className="hover:text-red-500"
onClick={() => {
void router.push(`${router.asPath}#delete-cdn-access-token?id=${edge.node.id}`);
}}
>
<TrashIcon />
</Button>
),
}))}
columns={[
{ key: 'name' },
{ key: 'alias' },
{ key: 'createdAt', align: 'right' },
{ key: 'delete', align: 'right' },
]}
/>
<div className="my-3.5 flex justify-end">
{target.data?.target?.cdnAccessTokens.pageInfo.hasPreviousPage ? (
<Button
variant="secondary"
size="large"
className="mr-2 px-5"
onClick={() => {
setEndCursors(cursors => {
if (cursors.length === 0) {
return cursors;
}
return cursors.slice(0, cursors.length - 1);
});
}}
>
Previous Page
</Button>
) : null}
<Button
variant="secondary"
size="large"
className="px-5"
disabled={!target.data?.target?.cdnAccessTokens.pageInfo.hasNextPage}
onClick={() => {
setEndCursors(cursors => {
if (!target.data?.target?.cdnAccessTokens.pageInfo.endCursor) {
return cursors;
}
return [...cursors, target.data?.target?.cdnAccessTokens.pageInfo.endCursor];
});
}}
>
Next Page
</Button>
</div>
{isCreateCDNAccessTokensModalOpen ? (
<CreateCDNAccessTokenModal
onCreateCDNAccessToken={() => {
reexecuteQuery({ requestPolicy: 'network-only' });
}}
onClose={closeModal}
/>
) : null}
{deleteCDNAccessTokenId ? (
<DeleteCDNAccessTokenModal
cdnAccessTokenId={deleteCDNAccessTokenId}
onDeletedAccessTokenId={() => {
reexecuteQuery({ requestPolicy: 'network-only' });
}}
onClose={closeModal}
/>
) : null}
</Card>
);
};

View file

@ -10,6 +10,7 @@ export const Heading = ({
children: ReactNode;
size?: 'lg' | 'xl' | '2xl';
className?: string;
id?: string;
}): ReactElement => {
const HeadingLevel = size === '2xl' ? 'h1' : 'h3';

View file

@ -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<ProjectType, string | undefined>;
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 (
<Modal open={isOpen} onOpenChange={toggleModalOpen} className="flex w-[650px] flex-col gap-5">
<Heading className="text-center">Connect to Hive</Heading>
{project && generating && (
<div className="mt-5 flex flex-col items-center gap-2 px-20">
<Spinner />
<Heading>Generating access...</Heading>
<p className="text-center">
Hive is now generating an authentication token and an URL you can use to fetch your{' '}
{taxonomy[project.type] ?? 'schema'}.
</p>
</div>
)}
{project && !generating && mutation.data && (
{query.data?.target && (
<>
<p className="text-sm text-gray-500">
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.
</p>
<span className="text-sm text-gray-500">You can use the following endpoint:</span>
<CopyValue value={mutation.data.createCdnToken.url} />
<CopyValue value={query.data.target.cdnUrl} />
<span className="text-sm text-gray-500">
To authenticate, use the following HTTP headers:
To authenticate, use the access HTTP headers. <br />
</span>
<Tag>X-Hive-CDN-Key: {mutation.data.createCdnToken.token}</Tag>
<p className="text-sm text-gray-500">
<Tag>
X-Hive-CDN-Key: {'<'}Your Access Token{'>'}
</Tag>
</p>
<p className="text-sm text-gray-500">
You can manage and generate CDN access tokens in the{' '}
<Link
variant="primary"
href={
'/' +
[
router.organizationId,
router.projectId,
router.targetId,
'settings#cdn-access-tokens',
].join('/')
}
>
target settings
</Link>
</p>
<p className="text-sm text-gray-500">
Read the{' '}
<Link

View file

@ -1,6 +0,0 @@
mutation createCdnToken($selector: TargetSelectorInput!) {
createCdnToken(selector: $selector) {
url
token
}
}

View file

@ -179,6 +179,7 @@ importers:
'@types/ioredis': 4.28.10
'@types/jest': 29.2.6
'@whatwg-node/fetch': 0.6.2
bcryptjs: 2.4.3
date-fns: 2.29.3
dockerode: 3.3.4
dotenv: 16.0.3
@ -197,11 +198,13 @@ importers:
'@app/gql': link:testkit/gql
'@aws-sdk/client-s3': 3.259.0
'@esm2cjs/execa': 6.1.1-cjs.1
'@graphql-hive/client': link:../packages/libraries/client
'@graphql-hive/core': link:../packages/libraries/core
'@graphql-typed-document-node/core': 3.1.1_graphql@16.6.0
'@trpc/client': 10.9.0_@trpc+server@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
@ -213,7 +216,6 @@ importers:
slonik: 30.1.2_wg2hxbo7txnklmvja4aeqnygfi
zod: 3.20.2
devDependencies:
'@graphql-hive/client': link:../packages/libraries/client
'@hive/rate-limit': link:../packages/services/rate-limit
'@hive/server': link:../packages/services/server
'@types/dockerode': 3.3.14

View file

@ -36,8 +36,9 @@
"@hive/cdn-script/artifact-storage-reader": [
"./packages/services/cdn-worker/src/artifact-storage-reader.ts"
],
"@hive/cdn-script/aws": ["./packages/services/cdn-worker/src/aws.ts"],
"@hive/cdn-script/cdn-token": ["./packages/services/cdn-worker/src/cdn-token.ts"],
"@hive/cdn-script/key-validation": ["./packages/services/cdn-worker/src/key-validation.ts"],
"@hive/cdn-script/aws": ["./packages/services/cdn-worker/src/aws.ts"],
"@hive/server": ["./packages/services/server/src/api.ts"],
"@hive/stripe-billing": ["./packages/services/stripe-billing/src/api.ts"],
"@hive/schema": ["./packages/services/schema/src/api.ts"],