diff --git a/Changelog.md b/Changelog.md index e19d8d6d1..3f0a903b3 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,8 @@ ## Upcoming +- **Improved** accessing neptune using assume role + ([#818](https://github.com/aws/graph-explorer/pull/818)) - **Added** ability to restore the graph from the previous session ([#826](https://github.com/aws/graph-explorer/pull/826)) - **Updated** styling of the connections screen diff --git a/packages/graph-explorer-proxy-server/package.json b/packages/graph-explorer-proxy-server/package.json index 6cee6e8f2..9dffbf8d2 100644 --- a/packages/graph-explorer-proxy-server/package.json +++ b/packages/graph-explorer-proxy-server/package.json @@ -14,6 +14,7 @@ "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-providers": "^3.758.0", + "@aws-sdk/client-sts": "^3.758.0", "@graph-explorer/shared": "workspace:*", "aws4": "^1.13.2", "body-parser": "^1.20.3", diff --git a/packages/graph-explorer-proxy-server/src/node-server.ts b/packages/graph-explorer-proxy-server/src/node-server.ts index 6b8055f2b..f881174b8 100644 --- a/packages/graph-explorer-proxy-server/src/node-server.ts +++ b/packages/graph-explorer-proxy-server/src/node-server.ts @@ -14,17 +14,28 @@ import { clientRoot, proxyServerRoot } from "./paths.js"; import { errorHandlingMiddleware, handleError } from "./error-handler.js"; import { BooleanStringSchema, env } from "./env.js"; import { pipeline } from "stream"; +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; const app = express(); const DEFAULT_SERVICE_TYPE = "neptune-db"; +interface AwsCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + expiration?: Date; +} + +const credentialCache: { [roleArn: string]: AwsCredentials } = {}; + interface DbQueryIncomingHttpHeaders extends IncomingHttpHeaders { queryid?: string; "graph-db-connection-url"?: string; "aws-neptune-region"?: string; "service-type"?: string; "db-query-logging-enabled"?: string; + "aws-assume-role-arn"?: string; } interface LoggerIncomingHttpHeaders extends IncomingHttpHeaders { @@ -34,23 +45,97 @@ interface LoggerIncomingHttpHeaders extends IncomingHttpHeaders { app.use(requestLoggingMiddleware()); -// Function to get IAM headers for AWS4 signing process. -async function getIAMHeaders(options: string | aws4.Request) { +// Function to check if the credentials are valid. +function areCredentialsValid(creds: AwsCredentials): boolean { + return creds.expiration + ? new Date(creds.expiration).getTime() - Date.now() > 5 * 60 * 1000 + : true; +} + +async function getCredentials( + awsAssumeRoleArn: string | undefined, + region: string | undefined +) { + if (awsAssumeRoleArn !== undefined && awsAssumeRoleArn !== "") { + if ( + credentialCache[awsAssumeRoleArn] && + areCredentialsValid(credentialCache[awsAssumeRoleArn]) + ) { + return credentialCache[awsAssumeRoleArn]; + } + try { + const command = new AssumeRoleCommand({ + RoleArn: awsAssumeRoleArn, + RoleSessionName: "GraphExplorerProxyServer", + }); + const stsClient = new STSClient({ region: region }); + const { Credentials } = await stsClient.send(command); + + if ( + !Credentials || + !Credentials.AccessKeyId || + !Credentials.SecretAccessKey || + !Credentials.SessionToken + ) { + throw new Error("Failed to assume role, no credentials returned"); + } + + proxyLogger.debug( + "Assumed role successfully using the provided role ARN %s, it will expire at: %s", + awsAssumeRoleArn, + Credentials.Expiration + ); + credentialCache[awsAssumeRoleArn] = { + accessKeyId: Credentials.AccessKeyId, + secretAccessKey: Credentials.SecretAccessKey, + sessionToken: Credentials.SessionToken, + expiration: Credentials.Expiration, + }; + + return credentialCache[awsAssumeRoleArn]; + } catch (error) { + proxyLogger.error( + "IAM is enabled but credentials cannot be assumed using the provided role ARN: %s, Error: %s", + awsAssumeRoleArn, + error + ); + return undefined; + } + } + const credentialProvider = fromNodeProviderChain(); const creds = await credentialProvider(); if (creds === undefined) { - throw new Error( + proxyLogger.error( "IAM is enabled but credentials cannot be found on the credential provider chain." ); + return undefined; } - const headers = aws4.sign(options, { + return { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + }; +} + +// Function to get IAM headers for AWS4 signing process. +async function getIAMHeaders( + options: string | aws4.Request, + region: string | undefined, + awsAssumeRoleArn: string | undefined +) { + const creds = await getCredentials(awsAssumeRoleArn, region); + if (!creds) { + throw new Error( + "IAM is enabled but credentials cannot be found or assumed." + ); + } + return aws4.sign(options, { accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey, ...(creds.sessionToken && { sessionToken: creds.sessionToken }), }); - - return headers; } // Function to retry fetch requests with exponential backoff. @@ -60,20 +145,25 @@ const retryFetch = async ( isIamEnabled: boolean, region: string | undefined, serviceType: string, + awsAssumeRoleArn: string | undefined, retryDelay = 10000, refetchMaxRetries = 1 ) => { for (let i = 0; i < refetchMaxRetries; i++) { if (isIamEnabled) { - const data = await getIAMHeaders({ - host: url.hostname, - port: url.port, - path: url.pathname + url.search, - service: serviceType, + const data = await getIAMHeaders( + { + host: url.hostname, + port: url.port, + path: url.pathname + url.search, + service: serviceType, + region, + method: options.method, + body: options.body ?? undefined, + }, region, - method: options.method, - body: options.body ?? undefined, - }); + awsAssumeRoleArn + ); options = { host: url.hostname, @@ -130,7 +220,8 @@ async function fetchData( options: RequestInit, isIamEnabled: boolean, region: string | undefined, - serviceType: string + serviceType: string, + awsAssumeRoleArn: string | undefined ) { try { const response = await retryFetch( @@ -138,7 +229,8 @@ async function fetchData( options, isIamEnabled, region, - serviceType + serviceType, + awsAssumeRoleArn ); // Set the headers from the fetch response to the client response @@ -201,6 +293,7 @@ app.post("/sparql", (req, res, next) => { const serviceType = isIamEnabled ? (headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; + const awsAssumeRoleArn = isIamEnabled ? headers["aws-assume-role-arn"] : ""; /// Function to cancel long running queries if the client disappears before completion async function cancelQuery() { @@ -221,7 +314,8 @@ app.post("/sparql", (req, res, next) => { }, isIamEnabled, region, - serviceType + serviceType, + awsAssumeRoleArn ); } catch (err) { // Not really an error @@ -275,7 +369,8 @@ app.post("/sparql", (req, res, next) => { requestOptions, isIamEnabled, region, - serviceType + serviceType, + awsAssumeRoleArn ); }); @@ -293,6 +388,7 @@ app.post("/gremlin", (req, res, next) => { const serviceType = isIamEnabled ? (headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; + const awsAssumeRoleArn = isIamEnabled ? headers["aws-assume-role-arn"] : ""; // Validate the input before making any external calls. const queryString = req.body.query; @@ -320,7 +416,8 @@ app.post("/gremlin", (req, res, next) => { { method: "GET" }, isIamEnabled, region, - serviceType + serviceType, + awsAssumeRoleArn ); } catch (err) { // Not really an error @@ -360,7 +457,8 @@ app.post("/gremlin", (req, res, next) => { requestOptions, isIamEnabled, region, - serviceType + serviceType, + awsAssumeRoleArn ); }); @@ -398,6 +496,7 @@ app.post("/openCypher", (req, res, next) => { const serviceType = isIamEnabled ? (headers["service-type"] ?? DEFAULT_SERVICE_TYPE) : ""; + const awsAssumeRoleArn = isIamEnabled ? headers["aws-assume-role-arn"] : ""; return fetchData( res, @@ -406,7 +505,8 @@ app.post("/openCypher", (req, res, next) => { requestOptions, isIamEnabled, region, - serviceType + serviceType, + awsAssumeRoleArn ); }); @@ -424,6 +524,7 @@ app.get("/summary", (req, res, next) => { }; const region = isIamEnabled ? headers["aws-neptune-region"] : ""; + const awsAssumeRoleArn = isIamEnabled ? headers["aws-assume-role-arn"] : ""; fetchData( res, @@ -432,7 +533,8 @@ app.get("/summary", (req, res, next) => { requestOptions, isIamEnabled, region, - serviceType + serviceType, + awsAssumeRoleArn ); }); @@ -450,6 +552,7 @@ app.get("/pg/statistics/summary", (req, res, next) => { }; const region = isIamEnabled ? headers["aws-neptune-region"] : ""; + const awsAssumeRoleArn = isIamEnabled ? headers["aws-assume-role-arn"] : ""; fetchData( res, @@ -458,7 +561,8 @@ app.get("/pg/statistics/summary", (req, res, next) => { requestOptions, isIamEnabled, region, - serviceType + serviceType, + awsAssumeRoleArn ); }); @@ -476,6 +580,7 @@ app.get("/rdf/statistics/summary", (req, res, next) => { }; const region = isIamEnabled ? headers["aws-neptune-region"] : ""; + const awsAssumeRoleArn = isIamEnabled ? headers["aws-assume-role-arn"] : ""; fetchData( res, @@ -484,7 +589,8 @@ app.get("/rdf/statistics/summary", (req, res, next) => { requestOptions, isIamEnabled, region, - serviceType + serviceType, + awsAssumeRoleArn ); }); diff --git a/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts b/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts index 8f421822f..bb9a4614f 100644 --- a/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts +++ b/packages/graph-explorer/src/connector/fetchDatabaseRequest.ts @@ -71,6 +71,7 @@ function getAuthHeaders( if (connection?.awsAuthEnabled) { headers["aws-neptune-region"] = connection.awsRegion || ""; headers["service-type"] = connection.serviceType || DEFAULT_SERVICE_TYPE; + headers["aws-assume-role-arn"] = connection.awsAssumeRoleArn || ""; } return { ...headers, ...typeHeaders }; diff --git a/packages/graph-explorer/src/core/connector.ts b/packages/graph-explorer/src/core/connector.ts index fb19de244..3904c611f 100644 --- a/packages/graph-explorer/src/core/connector.ts +++ b/packages/graph-explorer/src/core/connector.ts @@ -40,6 +40,7 @@ const activeConnectionSelector = equalSelector({ "graphDbUrl", "awsAuthEnabled", "awsRegion", + "awsAssumeRoleArn", "fetchTimeoutMs", "nodeExpansionLimit", ] as (keyof ConnectionConfig)[]; diff --git a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx index 6d0c0328f..11e11e6e1 100644 --- a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx +++ b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx @@ -43,6 +43,7 @@ type ConnectionForm = { awsAuthEnabled?: boolean; serviceType?: NeptuneServiceType; awsRegion?: string; + awsAssumeRoleArn?: string; fetchTimeoutEnabled: boolean; fetchTimeoutMs?: number; nodeExpansionLimitEnabled: boolean; @@ -72,6 +73,7 @@ function mapToConnection(data: Required): ConnectionConfig { awsAuthEnabled: data.awsAuthEnabled, serviceType: data.serviceType, awsRegion: data.awsRegion, + awsAssumeRoleArn: data.awsAssumeRoleArn, fetchTimeoutMs: data.fetchTimeoutEnabled ? data.fetchTimeoutMs : undefined, nodeExpansionLimit: data.nodeExpansionLimitEnabled ? data.nodeExpansionLimit @@ -164,6 +166,7 @@ const CreateConnection = ({ awsAuthEnabled: initialData?.awsAuthEnabled || false, serviceType: initialData?.serviceType || "neptune-db", awsRegion: initialData?.awsRegion || "", + awsAssumeRoleArn: initialData?.awsAssumeRoleArn || "", fetchTimeoutEnabled: initialData?.fetchTimeoutEnabled || false, fetchTimeoutMs: initialData?.fetchTimeoutMs, nodeExpansionLimitEnabled: initialData?.nodeExpansionLimitEnabled || false, @@ -335,6 +338,22 @@ const CreateConnection = ({ onValueChange={onFormChange("serviceType")} /> + + + + )} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 178b8a467..c335b15a0 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -39,6 +39,10 @@ export type ConnectionConfig = { * It is needed to sign requests. */ awsRegion?: string; + /** + * ARN of the role that the proxy-server should assume to sign requests. + */ + awsAssumeRoleArn?: string; /** * Number of milliseconds before aborting a request. * By default, undefined. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e137d46fc..869d8b61e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -434,6 +434,9 @@ importers: packages/graph-explorer-proxy-server: dependencies: + '@aws-sdk/client-sts': + specifier: ^3.758.0 + version: 3.758.0 '@aws-sdk/credential-providers': specifier: ^3.758.0 version: 3.758.0 @@ -576,6 +579,10 @@ packages: resolution: {integrity: sha512-BoGO6IIWrLyLxQG6txJw6RT2urmbtlwfggapNCrNPyYjlXpzTSJhBYjndg7TpDATFd0SXL0zm8y/tXsUXNkdYQ==} engines: {node: '>=18.0.0'} + '@aws-sdk/client-sts@3.758.0': + resolution: {integrity: sha512-ue9hbzjWNQmmyoSeWDRPwnYddsD3BVao5mSFA1kXFNVqWPEenjpkZ1xAlBVzHMMNoEz7LvGI+onXIHntNyiOLQ==} + engines: {node: '>=18.0.0'} + '@aws-sdk/core@3.758.0': resolution: {integrity: sha512-0RswbdR9jt/XKemaLNuxi2gGr4xGlHyGxkTdhSQzCyUe9A9OPCoLl3rIESRguQEech+oJnbHk/wuiwHqTuP9sg==} engines: {node: '>=18.0.0'} @@ -5999,6 +6006,50 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-sts@3.758.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.758.0 + '@aws-sdk/credential-provider-node': 3.758.0 + '@aws-sdk/middleware-host-header': 3.734.0 + '@aws-sdk/middleware-logger': 3.734.0 + '@aws-sdk/middleware-recursion-detection': 3.734.0 + '@aws-sdk/middleware-user-agent': 3.758.0 + '@aws-sdk/region-config-resolver': 3.734.0 + '@aws-sdk/types': 3.734.0 + '@aws-sdk/util-endpoints': 3.743.0 + '@aws-sdk/util-user-agent-browser': 3.734.0 + '@aws-sdk/util-user-agent-node': 3.758.0 + '@smithy/config-resolver': 4.0.1 + '@smithy/core': 3.1.5 + '@smithy/fetch-http-handler': 5.0.1 + '@smithy/hash-node': 4.0.1 + '@smithy/invalid-dependency': 4.0.1 + '@smithy/middleware-content-length': 4.0.1 + '@smithy/middleware-endpoint': 4.0.6 + '@smithy/middleware-retry': 4.0.7 + '@smithy/middleware-serde': 4.0.2 + '@smithy/middleware-stack': 4.0.1 + '@smithy/node-config-provider': 4.0.1 + '@smithy/node-http-handler': 4.0.3 + '@smithy/protocol-http': 5.0.1 + '@smithy/smithy-client': 4.1.6 + '@smithy/types': 4.1.0 + '@smithy/url-parser': 4.0.1 + '@smithy/util-base64': 4.0.0 + '@smithy/util-body-length-browser': 4.0.0 + '@smithy/util-body-length-node': 4.0.0 + '@smithy/util-defaults-mode-browser': 4.0.7 + '@smithy/util-defaults-mode-node': 4.0.7 + '@smithy/util-endpoints': 3.0.1 + '@smithy/util-middleware': 4.0.1 + '@smithy/util-retry': 4.0.1 + '@smithy/util-utf8': 4.0.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + '@aws-sdk/core@3.758.0': dependencies: '@aws-sdk/types': 3.734.0