Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 249 additions & 3 deletions web/src/core/adapters/s3Client/s3Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import {
getNewlyRequestedOrCachedTokenFactory,
createSessionStorageTokenPersistence
} from "core/tools/getNewlyRequestedOrCachedToken";
import { assert, is, type Equals } from "tsafe/assert";
import { assert, is, typeGuard, type Equals } from "tsafe";
import type { Oidc } from "core/ports/Oidc";
import { getS3UriKey, parseS3Uri } from "core/tools/S3Uri";
import { getS3UriKey, parseS3Uri, type S3Uri } from "core/tools/S3Uri";
import { exclude, id } from "tsafe";
import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex";
import type { OidcParams_Partial } from "core/ports/OnyxiaApi";
Expand Down Expand Up @@ -219,6 +219,84 @@ export function createS3Client(
})();

const s3Client: S3Client = {
getUnsignedDownloadUrl: ({ s3Uri }) => {
const url = new URL(params.url);
const pathname = url.pathname.endsWith("/")
? url.pathname.slice(0, -1)
: url.pathname;
const encodedKey = getS3UriKey(s3Uri)
.split("/")
.map(encodeURIComponent)
.join("/");

if (params.pathStyleAccess) {
url.pathname = `${pathname}/${encodeURIComponent(
s3Uri.bucket
)}/${encodedKey}`;
} else {
url.hostname = `${s3Uri.bucket}.${url.hostname}`;
url.pathname = `${pathname}/${encodedKey}`;
}

url.search = "";
url.hash = "";

return url.href;
},
getBucketPolicies: async ({ bucket }) => {
const { getAwsS3Client } = await prApi;

const { awsS3Client } = await getAwsS3Client();

const { GetBucketPolicyCommand, S3ServiceException } = await import(
"@aws-sdk/client-s3"
);

let policy: string | undefined;

try {
({ Policy: policy } = await awsS3Client.send(
new GetBucketPolicyCommand({
Bucket: bucket
})
));
} catch (error) {
if (error instanceof S3ServiceException) {
const httpStatusCode = error.$metadata?.httpStatusCode;

if (
httpStatusCode === 403 ||
httpStatusCode === 404 ||
httpStatusCode === 405 ||
httpStatusCode === 501 ||
error.name === "NoSuchBucketPolicy" ||
error.name === "NotImplemented" ||
error.name === "NotSupported"
) {
return undefined;
}
}

throw error;
}

if (policy === undefined) {
return undefined;
}

const bucketPolicies: unknown = JSON.parse(policy);

assert(
typeGuard<S3Client.BucketPolicies>(
bucketPolicies,
typeof bucketPolicies === "object" &&
bucketPolicies !== null &&
!Array.isArray(bucketPolicies)
)
);

return bucketPolicies;
},
getToken: async ({ doForceRenew }) => {
const { getNewlyRequestedOrCachedToken, clearCachedToken } = await prApi;

Expand Down Expand Up @@ -536,7 +614,175 @@ export function createS3Client(
}

return { isSuccess: true };
}
},
setS3UriPublicPrivatePolicy: (() => {
type BucketPolicyStatement = Record<string, unknown>;

function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" && value !== null && !Array.isArray(value)
);
}

function getPublicReadStatementSid(resourceArn: string): string {
return `OnyxiaPublicRead${fnv1aHashToHex(resourceArn)}`;
}

function getPublicReadResourceArn(s3Uri: S3Uri): string {
const key = getS3UriKey(s3Uri);

if (key === "") {
return `arn:aws:s3:::${s3Uri.bucket}/*`;
}

return `arn:aws:s3:::${s3Uri.bucket}/${key}${s3Uri.isDelimiterTerminated ? "*" : ""}`;
}

function createPublicReadStatement(params: {
resourceArn: string;
}): BucketPolicyStatement {
const { resourceArn } = params;

return {
Sid: getPublicReadStatementSid(resourceArn),
Effect: "Allow",
Principal: "*",
Action: "s3:GetObject",
Resource: resourceArn
};
}

function getStatements(bucketPolicies: S3Client.BucketPolicies): unknown[] {
const { Statement } = bucketPolicies;

if (Array.isArray(Statement)) {
return Statement;
}

if (Statement === undefined) {
return [];
}

return [Statement];
}

function removeManagedPublicReadStatement(params: {
statements: unknown[];
resourceArn: string;
}): unknown[] {
const { statements, resourceArn } = params;

const sid = getPublicReadStatementSid(resourceArn);

return statements.filter(statement => {
if (!isRecord(statement)) {
return true;
}

return statement.Sid !== sid || statement.Resource !== resourceArn;
});
}

return async ({ s3Uri, policy }) => {
const { getAwsS3Client } = await prApi;

const { awsS3Client } = await getAwsS3Client();

const {
GetBucketPolicyCommand,
PutBucketPolicyCommand,
DeleteBucketPolicyCommand,
S3ServiceException
} = await import("@aws-sdk/client-s3");

const Bucket = s3Uri.bucket;
const resourceArn = getPublicReadResourceArn(s3Uri);

let bucketPolicies: S3Client.BucketPolicies | undefined = undefined;

try {
const { Policy } = await awsS3Client.send(
new GetBucketPolicyCommand({
Bucket
})
);

if (Policy !== undefined) {
const parsedPolicy: unknown = JSON.parse(Policy);

assert(
typeGuard<S3Client.BucketPolicies>(
parsedPolicy,
isRecord(parsedPolicy)
)
);

bucketPolicies = parsedPolicy;
}
} catch (error) {
if (
error instanceof S3ServiceException &&
(error.$metadata?.httpStatusCode === 404 ||
error.name === "NoSuchBucketPolicy")
) {
bucketPolicies = undefined;
} else {
assert(is<Error>(error));

return {
isSuccess: false,
errorMessage: error.message
};
}
}

const basePolicy =
bucketPolicies ??
id<S3Client.BucketPolicies>({
Version: "2012-10-17"
});

const statements = removeManagedPublicReadStatement({
statements: getStatements(basePolicy),
resourceArn
});

if (policy === "public") {
statements.push(createPublicReadStatement({ resourceArn }));
}

try {
if (statements.length === 0) {
if (bucketPolicies !== undefined) {
await awsS3Client.send(
new DeleteBucketPolicyCommand({
Bucket
})
);
}
} else {
await awsS3Client.send(
new PutBucketPolicyCommand({
Bucket,
Policy: JSON.stringify({
...basePolicy,
Statement: statements
})
})
);
}
} catch (error) {
assert(is<Error>(error));

return {
isSuccess: false,
errorMessage: error.message
};
}

return { isSuccess: true };
};
})()
};

return s3Client;
Expand Down
13 changes: 13 additions & 0 deletions web/src/core/ports/S3Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type S3Client = {
validityDurationSecond: number;
}) => Promise<string>;

getUnsignedDownloadUrl: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter }) => string;

getObjectContent: (params: {
s3Uri: S3Uri.NonTerminatedByDelimiter;
range: `bytes=0-${number}` | undefined;
Expand All @@ -57,6 +59,15 @@ export type S3Client = {
errorMessage: string;
}
>;

getBucketPolicies: (params: {
bucket: string;
}) => Promise<S3Client.BucketPolicies | undefined>;

setS3UriPublicPrivatePolicy: (params: {
s3Uri: S3Uri;
policy: "public" | "private";
}) => Promise<{ isSuccess: true } | { isSuccess: false; errorMessage: string }>;
};

export namespace S3Client {
Expand Down Expand Up @@ -86,4 +97,6 @@ export namespace S3Client {
errorCase: "access denied" | "no such bucket";
};
}

export type BucketPolicies = Record<string, unknown>;
}
4 changes: 2 additions & 2 deletions web/src/core/usecases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import * as userAuthentication from "./userAuthentication";
import * as userProfileForm from "./userProfileForm";
import * as userConfigs from "./userConfigs";
import * as secretsEditor from "./secretsEditor";
import * as s3CodeSnippets from "./s3CodeSnippets";
import * as s3ProfilesDetailsUiController from "./s3ProfilesDetailsUiController";
import * as k8sCodeSnippets from "./k8sCodeSnippets";
import * as vaultCredentials from "./vaultCredentials";
import * as sqlOlapShell from "./sqlOlapShell";
Expand Down Expand Up @@ -40,7 +40,7 @@ export const usecases = {
userProfileForm,
userConfigs,
secretsEditor,
s3CodeSnippets,
s3ProfilesDetailsUiController,
k8sCodeSnippets,
vaultCredentials,
sqlOlapShell,
Expand Down
2 changes: 1 addition & 1 deletion web/src/core/usecases/k8sCodeSnippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export const thunks = {
async (...args) => {
const [dispatch, getState, rootContext] = args;

if (getState().s3CodeSnippets.isRefreshing) {
if (getState()[name].isRefreshing) {
return;
}

Expand Down
Loading