Cloud user authentication, S3 buckets (#4043)
* WIP: Cloud authentication * User token authentication * Prepare frontend for editing access to remote cache * WIP: Implement S3 bucket in database * Create S3Bucket * Add RemoteCachePageStore tests * Fix lint issues * Minor changes * Skip using master key for test environment * Hardcode test master key * Remove project_update_storage_service * Ignore frontend folder from autoloading * Remove sign_out_user file
This commit is contained in:
parent
d0c184904d
commit
61d8e1b894
|
@ -11,7 +11,7 @@ on:
|
|||
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
RAILS_MASTER_KEY: ${{ secrets.CLOUD_RAILS_MASTER_KEY }}
|
||||
RAILS_MASTER_KEY: 09a0926fc36e14a27bcc96e7f9323d05
|
||||
RUBY_VERSION: 3.0.3
|
||||
NODE_VERSION: 16.13.0
|
||||
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"editor.formatOnSave": false,
|
||||
"[yml]": {
|
||||
"editor.formatOnSave": false
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[json]": {
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[ruby]": {
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"files.exclude": {
|
||||
"**/node_modules": true,
|
||||
},
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "net/http"
|
||||
|
||||
class AuthController < ApplicationController
|
||||
def authenticate
|
||||
response = Net::HTTP.get_response(URI.parse("http://127.0.0.1:4545/auth?token=#{current_user.token}&account=#{current_user.account.name}"))
|
||||
render(html: response.body.html_safe)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CacheController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
before_action :authenticate_user_from_token!
|
||||
|
||||
def cache
|
||||
# TODO: Check if a file for a given hash, framework, and project exists
|
||||
end
|
||||
|
||||
def authenticate_user_from_token!
|
||||
authenticate_or_request_with_http_token do |token, options|
|
||||
user = User.find_by!(token: token)
|
||||
if user
|
||||
sign_in(user, store: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,10 +1,5 @@
|
|||
import OrganizationStore from '../stores/OrganizationStore';
|
||||
import { Role } from '../graphql/types';
|
||||
import {
|
||||
ApolloClient,
|
||||
InMemoryCache,
|
||||
NormalizedCacheObject,
|
||||
} from '@apollo/client';
|
||||
|
||||
jest.mock('@apollo/client');
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ import NewProject from './NewProject';
|
|||
import Dashboard from './Dashboard';
|
||||
import Home from './Home';
|
||||
import { useMeQuery } from '@/graphql/types';
|
||||
import RemoteCache from './RemoteCache';
|
||||
import RemoteCachePage from './pages/remote-cache/RemoteCachePage';
|
||||
import OrganizationPage from './pages/organization/OrganizationPage';
|
||||
|
||||
import { AppProvider } from '@shopify/polaris';
|
||||
|
@ -51,7 +51,7 @@ const AppRoutes = () => {
|
|||
/>
|
||||
<Route path="/:accountName/:projectName" element={<Home />}>
|
||||
<Route path="" element={<Dashboard />} />
|
||||
<Route path="remote-cache" element={<RemoteCache />} />
|
||||
<Route path="remote-cache" element={<RemoteCachePage />} />
|
||||
<Route path="organization" element={<OrganizationPage />} />
|
||||
</Route>
|
||||
<Route path="/new" element={<NewProject />} />
|
||||
|
|
|
@ -41,7 +41,6 @@ import {
|
|||
} from '@shopify/polaris-icons';
|
||||
import { HomeStore, HomeStoreContext } from '@/stores/HomeStore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { runInAction } from 'mobx';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
|
||||
const Home = observer(() => {
|
||||
|
@ -278,17 +277,15 @@ const Home = observer(() => {
|
|||
</Modal>
|
||||
);
|
||||
|
||||
const { accountName: organizationName } = useParams();
|
||||
|
||||
const client = useApolloClient();
|
||||
const [homeStore] = useState(new HomeStore(client));
|
||||
|
||||
useEffect(() => {
|
||||
if (!organizationName) {
|
||||
if (!accountName || !projectName) {
|
||||
return;
|
||||
}
|
||||
homeStore.load(organizationName);
|
||||
}, [organizationName]);
|
||||
homeStore.load(projectName, accountName);
|
||||
}, [accountName, projectName]);
|
||||
|
||||
return (
|
||||
<HomeStoreContext.Provider value={homeStore}>
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Page } from '@shopify/polaris';
|
||||
|
||||
const RemoteCache = () => {
|
||||
return (
|
||||
<Page title="Remote Cache">
|
||||
<p>My Remote Cache</p>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemoteCache;
|
|
@ -154,7 +154,7 @@ const OrganizationPage = observer(() => {
|
|||
onChange={(newValue) => {
|
||||
organizationPageStore.inviteeEmail = newValue;
|
||||
}}
|
||||
></TextField>
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
onClick={() => {
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import {
|
||||
Page,
|
||||
FormLayout,
|
||||
TextField,
|
||||
Card,
|
||||
Button,
|
||||
Select,
|
||||
} from '@shopify/polaris';
|
||||
import RemoteCachePageStore from './RemoteCachePageStore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { HomeStoreContext } from '@/stores/HomeStore';
|
||||
import { runInAction } from 'mobx';
|
||||
|
||||
const RemoteCachePage = observer(() => {
|
||||
const client = useApolloClient();
|
||||
const [remoteCachePageStore] = useState(
|
||||
() => new RemoteCachePageStore(client),
|
||||
);
|
||||
const { projectStore } = useContext(HomeStoreContext);
|
||||
|
||||
const handleSelectChange = useCallback(
|
||||
(newValue) => {
|
||||
runInAction(() => {
|
||||
remoteCachePageStore.selectedOption = newValue;
|
||||
});
|
||||
},
|
||||
[remoteCachePageStore],
|
||||
);
|
||||
|
||||
const handleBucketNameChange = useCallback((newValue) => {
|
||||
runInAction(() => {
|
||||
remoteCachePageStore.bucketName = newValue;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAccessKeyIdChange = useCallback((newValue) => {
|
||||
runInAction(() => {
|
||||
remoteCachePageStore.accessKeyId = newValue;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSecretAccessKeyChange = useCallback((newValue) => {
|
||||
runInAction(() => {
|
||||
remoteCachePageStore.secretAccessKey = newValue;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleApplyChangesClicked = useCallback(() => {
|
||||
if (
|
||||
projectStore.project === undefined ||
|
||||
projectStore.project === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
remoteCachePageStore.applyChangesButtonClicked(
|
||||
projectStore.project.account.id,
|
||||
);
|
||||
}, [remoteCachePageStore, projectStore]);
|
||||
|
||||
return (
|
||||
<Page title="Remote Cache">
|
||||
<Card title="S3 Bucket setup" sectioned>
|
||||
<FormLayout>
|
||||
<Select
|
||||
label="S3 Bucket"
|
||||
options={remoteCachePageStore.bucketOptions}
|
||||
onChange={handleSelectChange}
|
||||
value={remoteCachePageStore.selectedOption}
|
||||
/>
|
||||
<TextField
|
||||
type="text"
|
||||
label="Bucket name"
|
||||
value={remoteCachePageStore.bucketName}
|
||||
onChange={handleBucketNameChange}
|
||||
/>
|
||||
<TextField
|
||||
type="text"
|
||||
label="Access key ID"
|
||||
value={remoteCachePageStore.accessKeyId}
|
||||
onChange={handleAccessKeyIdChange}
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
label="Secret access key"
|
||||
value={remoteCachePageStore.secretAccessKey}
|
||||
onChange={handleSecretAccessKeyChange}
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
disabled={
|
||||
remoteCachePageStore.isApplyChangesButtonDisabled
|
||||
}
|
||||
onClick={handleApplyChangesClicked}
|
||||
>
|
||||
Create bucket
|
||||
</Button>
|
||||
</FormLayout>
|
||||
</Card>
|
||||
</Page>
|
||||
);
|
||||
});
|
||||
|
||||
export default RemoteCachePage;
|
|
@ -0,0 +1,54 @@
|
|||
import { CreateS3BucketDocument } from '../../../graphql/types';
|
||||
import { ApolloClient } from '@apollo/client';
|
||||
import { SelectOption } from '@shopify/polaris';
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
|
||||
class RemoteCachePageStore {
|
||||
bucketName = '';
|
||||
accessKeyId = '';
|
||||
secretAccessKey = '';
|
||||
bucketOptions: SelectOption[] = [
|
||||
{
|
||||
label: 'Create new bucket',
|
||||
value: 'new',
|
||||
},
|
||||
];
|
||||
selectedOption = 'new';
|
||||
|
||||
client: ApolloClient<object>;
|
||||
|
||||
constructor(client: ApolloClient<object>) {
|
||||
this.client = client;
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
get isApplyChangesButtonDisabled() {
|
||||
return (
|
||||
this.bucketName.length === 0 ||
|
||||
this.accessKeyId.length === 0 ||
|
||||
this.secretAccessKey.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
get isCreatingBucket() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async applyChangesButtonClicked(accountId: string) {
|
||||
if (this.isCreatingBucket) {
|
||||
await this.client.mutate({
|
||||
mutation: CreateS3BucketDocument,
|
||||
variables: {
|
||||
input: {
|
||||
name: this.bucketName,
|
||||
accessKeyId: this.accessKeyId,
|
||||
secretAccessKey: this.secretAccessKey,
|
||||
accountId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default RemoteCachePageStore;
|
|
@ -0,0 +1,43 @@
|
|||
import RemoteCachePageStore from '../RemoteCachePageStore';
|
||||
|
||||
jest.mock('@apollo/client');
|
||||
|
||||
describe('RemoteCachePageStore', () => {
|
||||
const client = {
|
||||
query: jest.fn(),
|
||||
mutate: jest.fn(),
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps apply changes button disabled when not all fields are filled', async () => {
|
||||
// Given
|
||||
const remoteCachePageStore = new RemoteCachePageStore(client);
|
||||
|
||||
// When
|
||||
remoteCachePageStore.bucketName = '1';
|
||||
remoteCachePageStore.accessKeyId = '1';
|
||||
|
||||
// Then
|
||||
expect(
|
||||
remoteCachePageStore.isApplyChangesButtonDisabled,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('marks apply changes button enabled when all fields are filled', async () => {
|
||||
// Given
|
||||
const remoteCachePageStore = new RemoteCachePageStore(client);
|
||||
|
||||
// When
|
||||
remoteCachePageStore.bucketName = '1';
|
||||
remoteCachePageStore.accessKeyId = '1';
|
||||
remoteCachePageStore.secretAccessKey = '1';
|
||||
|
||||
// Then
|
||||
expect(
|
||||
remoteCachePageStore.isApplyChangesButtonDisabled,
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
mutation CreateS3Bucket($input: CreateS3BucketInput!) {
|
||||
createS3Bucket(input: $input) {
|
||||
name
|
||||
accessKeyId
|
||||
secretAccessKey
|
||||
}
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
query Me {
|
||||
me {
|
||||
id
|
||||
email
|
||||
avatarUrl
|
||||
...UserBasicInfo
|
||||
lastVisitedProject {
|
||||
slug
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
query Project($name: String!, $accountName: String!) {
|
||||
project(name: $name, accountName: $accountName) {
|
||||
account {
|
||||
id
|
||||
owner {
|
||||
... on Organization {
|
||||
id
|
||||
|
|
|
@ -48,6 +48,16 @@ export type CreateProjectInput = {
|
|||
organizationName?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** Autogenerated input type of CreateS3Bucket */
|
||||
export type CreateS3BucketInput = {
|
||||
accessKeyId: Scalars['String'];
|
||||
accountId: Scalars['ID'];
|
||||
/** A unique identifier for the client performing the mutation. */
|
||||
clientMutationId?: InputMaybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
secretAccessKey: Scalars['String'];
|
||||
};
|
||||
|
||||
export type Invitation = {
|
||||
__typename?: 'Invitation';
|
||||
inviteeEmail: Scalars['ID'];
|
||||
|
@ -72,6 +82,8 @@ export type Mutation = {
|
|||
changeUserRole: User;
|
||||
/** Creates a new project */
|
||||
createProject: Project;
|
||||
/** Create new S3 bucket */
|
||||
createS3Bucket: S3Bucket;
|
||||
/** Invite a user to a given organization */
|
||||
inviteUser: Invitation;
|
||||
/** Remove user from a given organization */
|
||||
|
@ -94,6 +106,11 @@ export type MutationCreateProjectArgs = {
|
|||
};
|
||||
|
||||
|
||||
export type MutationCreateS3BucketArgs = {
|
||||
input: CreateS3BucketInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationInviteUserArgs = {
|
||||
input: InviteUserInput;
|
||||
};
|
||||
|
@ -169,6 +186,13 @@ export enum Role {
|
|||
User = 'user'
|
||||
}
|
||||
|
||||
export type S3Bucket = {
|
||||
__typename?: 'S3Bucket';
|
||||
accessKeyId: Scalars['String'];
|
||||
name: Scalars['String'];
|
||||
secretAccessKey?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
__typename?: 'User';
|
||||
account: Account;
|
||||
|
@ -201,6 +225,13 @@ export type CreateProjectMutationVariables = Exact<{
|
|||
|
||||
export type CreateProjectMutation = { __typename?: 'Mutation', createProject: { __typename?: 'Project', slug: string } };
|
||||
|
||||
export type CreateS3BucketMutationVariables = Exact<{
|
||||
input: CreateS3BucketInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateS3BucketMutation = { __typename?: 'Mutation', createS3Bucket: { __typename?: 'S3Bucket', name: string, accessKeyId: string, secretAccessKey?: string | null | undefined } };
|
||||
|
||||
export type InvitationQueryVariables = Exact<{
|
||||
token: Scalars['String'];
|
||||
}>;
|
||||
|
@ -218,7 +249,7 @@ export type InviteUserMutation = { __typename?: 'Mutation', inviteUser: { __type
|
|||
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type MeQuery = { __typename?: 'Query', me: { __typename?: 'User', id: string, email: string, avatarUrl?: string | null | undefined, lastVisitedProject?: { __typename?: 'Project', slug: string } | null | undefined, projects: Array<{ __typename?: 'Project', name: string, slug: string }> } };
|
||||
export type MeQuery = { __typename?: 'Query', me: { __typename?: 'User', id: string, email: string, avatarUrl?: string | null | undefined, lastVisitedProject?: { __typename?: 'Project', slug: string } | null | undefined, projects: Array<{ __typename?: 'Project', name: string, slug: string }>, account: { __typename?: 'Account', name: string } } };
|
||||
|
||||
export type MyAccountsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
@ -243,7 +274,7 @@ export type ProjectQueryVariables = Exact<{
|
|||
}>;
|
||||
|
||||
|
||||
export type ProjectQuery = { __typename?: 'Query', project?: { __typename?: 'Project', account: { __typename?: 'Account', owner: { __typename?: 'Organization', id: string } | { __typename?: 'User', id: string } } } | null | undefined };
|
||||
export type ProjectQuery = { __typename?: 'Query', project?: { __typename?: 'Project', account: { __typename?: 'Account', id: string, owner: { __typename?: 'Organization', id: string } | { __typename?: 'User', id: string } } } | null | undefined };
|
||||
|
||||
export type RemoveUserMutationVariables = Exact<{
|
||||
input: RemoveUserInput;
|
||||
|
@ -367,6 +398,41 @@ export function useCreateProjectMutation(baseOptions?: Apollo.MutationHookOption
|
|||
export type CreateProjectMutationHookResult = ReturnType<typeof useCreateProjectMutation>;
|
||||
export type CreateProjectMutationResult = Apollo.MutationResult<CreateProjectMutation>;
|
||||
export type CreateProjectMutationOptions = Apollo.BaseMutationOptions<CreateProjectMutation, CreateProjectMutationVariables>;
|
||||
export const CreateS3BucketDocument = gql`
|
||||
mutation CreateS3Bucket($input: CreateS3BucketInput!) {
|
||||
createS3Bucket(input: $input) {
|
||||
name
|
||||
accessKeyId
|
||||
secretAccessKey
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type CreateS3BucketMutationFn = Apollo.MutationFunction<CreateS3BucketMutation, CreateS3BucketMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useCreateS3BucketMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useCreateS3BucketMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useCreateS3BucketMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [createS3BucketMutation, { data, loading, error }] = useCreateS3BucketMutation({
|
||||
* variables: {
|
||||
* input: // value for 'input'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useCreateS3BucketMutation(baseOptions?: Apollo.MutationHookOptions<CreateS3BucketMutation, CreateS3BucketMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<CreateS3BucketMutation, CreateS3BucketMutationVariables>(CreateS3BucketDocument, options);
|
||||
}
|
||||
export type CreateS3BucketMutationHookResult = ReturnType<typeof useCreateS3BucketMutation>;
|
||||
export type CreateS3BucketMutationResult = Apollo.MutationResult<CreateS3BucketMutation>;
|
||||
export type CreateS3BucketMutationOptions = Apollo.BaseMutationOptions<CreateS3BucketMutation, CreateS3BucketMutationVariables>;
|
||||
export const InvitationDocument = gql`
|
||||
query Invitation($token: String!) {
|
||||
invitation(token: $token) {
|
||||
|
@ -452,9 +518,7 @@ export type InviteUserMutationOptions = Apollo.BaseMutationOptions<InviteUserMut
|
|||
export const MeDocument = gql`
|
||||
query Me {
|
||||
me {
|
||||
id
|
||||
email
|
||||
avatarUrl
|
||||
...UserBasicInfo
|
||||
lastVisitedProject {
|
||||
slug
|
||||
}
|
||||
|
@ -464,7 +528,7 @@ export const MeDocument = gql`
|
|||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
${UserBasicInfoFragmentDoc}`;
|
||||
|
||||
/**
|
||||
* __useMeQuery__
|
||||
|
@ -608,6 +672,7 @@ export const ProjectDocument = gql`
|
|||
query Project($name: String!, $accountName: String!) {
|
||||
project(name: $name, accountName: $accountName) {
|
||||
account {
|
||||
id
|
||||
owner {
|
||||
... on Organization {
|
||||
id
|
||||
|
|
|
@ -1,23 +1,29 @@
|
|||
import { makeAutoObservable } from 'mobx';
|
||||
import OrganizationStore from './OrganizationStore';
|
||||
import UserStore from './UserStore';
|
||||
import ProjectStore from './ProjectStore';
|
||||
import { createContext } from 'react';
|
||||
import { ApolloClient } from '@apollo/client';
|
||||
|
||||
export class HomeStore {
|
||||
userStore: UserStore;
|
||||
organizationStore: OrganizationStore;
|
||||
projectStore: ProjectStore;
|
||||
client: ApolloClient<object>;
|
||||
|
||||
constructor(client: ApolloClient<object>) {
|
||||
makeAutoObservable(this);
|
||||
this.userStore = new UserStore(client);
|
||||
this.organizationStore = new OrganizationStore(client);
|
||||
this.projectStore = new ProjectStore(client);
|
||||
}
|
||||
|
||||
async load(organizationName: string) {
|
||||
async load(projectName: string, accountName: string) {
|
||||
await this.userStore.load();
|
||||
await this.organizationStore.load(organizationName);
|
||||
if (this.userStore.me.account.name !== accountName) {
|
||||
await this.organizationStore.load(accountName);
|
||||
}
|
||||
await this.projectStore.load(projectName, accountName);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import { ProjectQuery, ProjectDocument } from '@/graphql/types';
|
||||
import { ApolloClient } from '@apollo/client';
|
||||
import { makeAutoObservable, runInAction } from 'mobx';
|
||||
|
||||
export default class ProjectStore {
|
||||
project: ProjectQuery['project'];
|
||||
client: ApolloClient<object>;
|
||||
constructor(client: ApolloClient<object>) {
|
||||
this.client = client;
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
async load(name: string, accountName: string) {
|
||||
const { data } = await this.client.query({
|
||||
query: ProjectDocument,
|
||||
variables: {
|
||||
name,
|
||||
accountName,
|
||||
},
|
||||
});
|
||||
runInAction(() => {
|
||||
this.project = data.project;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Mutations
|
||||
class CreateS3Bucket < ::Mutations::BaseMutation
|
||||
argument :name, String, required: true
|
||||
argument :access_key_id, String, required: true
|
||||
argument :secret_access_key, String, required: true
|
||||
argument :account_id, ID, required: true
|
||||
|
||||
type Types::S3BucketType
|
||||
|
||||
def resolve(attributes)
|
||||
S3BucketCreateService.call(**attributes)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -27,5 +27,10 @@ module Types
|
|||
null: false,
|
||||
description: "Accept invitation based on a token",
|
||||
mutation: Mutations::AcceptInvitation
|
||||
field :create_s3_bucket,
|
||||
S3BucketType,
|
||||
null: false,
|
||||
description: "Create new S3 bucket",
|
||||
mutation: Mutations::CreateS3Bucket
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
class S3BucketType < Types::BaseObject
|
||||
field :name, String, null: false
|
||||
field :access_key_id, String, null: false
|
||||
field :secret_access_key, String, null: true
|
||||
end
|
||||
end
|
|
@ -4,6 +4,7 @@ class Account < ApplicationRecord
|
|||
# Associations
|
||||
belongs_to :owner, polymorphic: true, optional: false
|
||||
has_many :projects
|
||||
has_many :s3_buckets, class_name: "S3Bucket", dependent: :destroy
|
||||
|
||||
# Validations
|
||||
validates :name, exclusion: Defaults.fetch(:blocklisted_slug_keywords)
|
||||
|
|
|
@ -8,6 +8,7 @@ class Project < ApplicationRecord
|
|||
|
||||
# Associations
|
||||
belongs_to :account, optional: false
|
||||
belongs_to :remote_cache_storage, polymorphic: true, optional: true
|
||||
|
||||
# Validations
|
||||
validates :name, exclusion: Defaults.fetch(:blocklisted_slug_keywords)
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class S3Bucket < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :account, optional: false
|
||||
has_many :projects, as: :remote_cache_storage
|
||||
end
|
|
@ -13,6 +13,11 @@ class User < ApplicationRecord
|
|||
# Callbacks
|
||||
before_validation :create_account, if: -> (user) { user.account.nil? }
|
||||
|
||||
include TokenAuthenticatable
|
||||
|
||||
# Token authenticatable
|
||||
autogenerates_token :token
|
||||
|
||||
# Devise
|
||||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :validatable,
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class S3BucketCreateService < ApplicationService
|
||||
attr_reader :name, :access_key_id, :secret_access_key, :account_id
|
||||
|
||||
module Error
|
||||
class DuplicatedName < CloudError
|
||||
attr_reader :name
|
||||
|
||||
def initialize(name)
|
||||
@name = name
|
||||
end
|
||||
|
||||
def message
|
||||
"Bucket #{name} already exists."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(name:, access_key_id:, secret_access_key:, account_id:)
|
||||
super()
|
||||
@name = name
|
||||
@access_key_id = access_key_id
|
||||
@secret_access_key = secret_access_key
|
||||
@account_id = account_id
|
||||
end
|
||||
|
||||
def call
|
||||
if !S3Bucket.find_by(name: name, account_id: account_id).nil?
|
||||
raise Error::DuplicatedName.new(name)
|
||||
end
|
||||
cipher = OpenSSL::Cipher::AES.new(256, :CBC)
|
||||
cipher.encrypt
|
||||
cipher.key = Digest::MD5.hexdigest(Rails.application.credentials[:secret_key_base])
|
||||
iv = cipher.random_iv
|
||||
|
||||
encrypted_secret_access_key = cipher.update(secret_access_key) + cipher.final
|
||||
account = Account.find(account_id)
|
||||
account.s3_buckets.create!(
|
||||
name: name,
|
||||
access_key_id: access_key_id,
|
||||
secret_access_key: Base64.encode64(encrypted_secret_access_key),
|
||||
iv: Base64.encode64(iv)
|
||||
)
|
||||
end
|
||||
end
|
|
@ -17,6 +17,7 @@ module TuistCloud
|
|||
# Autoloading
|
||||
config.autoload_once_paths << "#{root}/app/lib/defaults"
|
||||
config.autoload_once_paths << "#{root}/app/lib/secrets"
|
||||
Rails.autoloaders.main.ignore("#{root}/app/frontend")
|
||||
|
||||
# URLs
|
||||
Rails.application.routes.default_url_options[:host] = config.defaults[:urls][:app]
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
09a0926fc36e14a27bcc96e7f9323d05
|
|
@ -0,0 +1 @@
|
|||
xHetGBY5uvxsw+hSeL+B7hqN+YUO3TvO7omX8Fa5qdNPEwMRda4LGGR2+YKM43AVgOPb018uTRs6hVhJsSNivzYIVzjK3I+XwyTm2T3WNISJV18Y9xuLmeeicWdsMDco/s/QAX2owJYDBfcDhhwB9Suy8DELgjlSVMYf/GbrYGDFykqyoDB3FjHhHkplmrzBFn9N6kLMig+L2D/IegyzR7NTyOcGU7oe8mCZhhwq2faERS77slbCDmViqBUNunDafwR5E1bFgKgTvsxc4cMjvd3lXBYqi2UL+crV9zyCTbbsF/PRGuMu1FKk17l88001d8OmHChHpq+yhuUME4nuibVOwqjd6XPnE9E0WaxSjUPkpG+jSTin/tpJgrHybWrb+egPOVfMnLXznQrEs5ymqhr9Hf71TeIQFi1rr26bosbIGCtc/tlsW25vAyKSbz3RK00fUPQTq0IDyL925MEhStwKp2LmcJdG0/z3LtWH+APgcBGOzJ/LI/ZSpp+/BqeQjcYsNwqaqYmuJkZ/gx/XDrfopUR/YmUOd5OIuHAj8RzTALR5PZIGCAFlAK/Twujpcxh9w7MsA401a5iHkEFLd4gHTpv5OJZMyd74YwaBGtIQ++axE5o8wd2VTEqzfjtJr5TPcd75wRMW0ZaL69tDfj6oDT8sGisUpbcysA/EVBP/HOkwX/iGRfwkFSVKdg88KEoqtidb0GsYH7Jt/nxs+VNGa4VvYgKm3FssuY33Jel6AzyjGMMGCCwkHnyxS6zJ0RbntgvJ674zkEv/at+n/uvkeEcoE+RGxhvbR5w+Zc4g48VV6oOSLVTo/i9inHzE9uUbV8Sj4s4iWqEq/kmDWgb6qrA7sojCqqn8i13pNHnqLTpGtHseUCd9akSctBh8AeZW7MKCXXrHmyxHE8/EhdlLlocNEMCndyYMMe2RUEfWXblIBGLm8l01xbFK588zZMGWMeeMLPW0uTwcL9uKYeA0NAkVM571wB/maaAtQg8nQRhuDn0PH/IaGKpnztwS5H/3I0nj612qjJh5/FOK3aqNAnwtBV2K0Ibs+jRJQa005dRKbI3J5ZAUnoAySG3O5p75DSPZ4otKGmAZaP6wgbn8WWfqNuCt8rK7zv3yMbTAGHI3VDQS97BpO1Ac6b4G3VlVvOsEp3I3Lmgv9NfoTSatUe8eCka53LrqwlJwxYiLLfxotUHHUPYtoyTqQHq3Khow9IVX70WjDvAXsYInMVk8HWbrB+tvlsJPnf8FDnbkfSt2dIbw2bvtR6l++IGuPveB--hh/bOm5lj2GSO4zu--xet8NAy4WirYcFu3MU9ikA==
|
|
@ -19,5 +19,9 @@ Rails.application.routes.draw do
|
|||
|
||||
get "/invitations/:token/", to: "application#app", as: :invitation
|
||||
|
||||
get "/auth", to: "auth#authenticate"
|
||||
|
||||
get "/api/cache", to: "cache#cache"
|
||||
|
||||
get "/(*all)", to: "application#app"
|
||||
end
|
||||
|
|
|
@ -6,6 +6,7 @@ class DeviseCreateUsers < ActiveRecord::Migration[6.1]
|
|||
## Database authenticatable
|
||||
t.string(:email, null: false, default: "")
|
||||
t.string(:encrypted_password, null: false, default: "")
|
||||
t.string(:token, index: { unique: true }, null: false, limit: 100)
|
||||
|
||||
## Recoverable
|
||||
t.string(:reset_password_token)
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddS3BucketsTable < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table(:s3_buckets) do |t|
|
||||
t.string(:name, null: false)
|
||||
t.string(:access_key_id, null: false)
|
||||
t.string(:secret_access_key, null: true)
|
||||
t.string(:iv, null: true)
|
||||
t.references(:project, polymorphic: true)
|
||||
t.timestamps(null: false)
|
||||
end
|
||||
|
||||
add_reference(:s3_buckets, :account, foreign_key: true, null: false)
|
||||
add_index(:s3_buckets, [:name, :account_id], unique: true)
|
||||
end
|
||||
end
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2021_12_22_143721) do
|
||||
ActiveRecord::Schema.define(version: 2022_01_23_191852) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
@ -65,9 +65,25 @@ ActiveRecord::Schema.define(version: 2021_12_22_143721) do
|
|||
t.index ["resource_type", "resource_id"], name: "index_roles_on_resource"
|
||||
end
|
||||
|
||||
create_table "s3_buckets", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "access_key_id", null: false
|
||||
t.string "secret_access_key"
|
||||
t.string "iv"
|
||||
t.string "project_type"
|
||||
t.bigint "project_id"
|
||||
t.datetime "created_at", precision: 6, null: false
|
||||
t.datetime "updated_at", precision: 6, null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.index ["account_id"], name: "index_s3_buckets_on_account_id"
|
||||
t.index ["name", "account_id"], name: "index_s3_buckets_on_name_and_account_id", unique: true
|
||||
t.index ["project_type", "project_id"], name: "index_s3_buckets_on_project"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.string "email", default: "", null: false
|
||||
t.string "encrypted_password", default: "", null: false
|
||||
t.string "token", limit: 100, null: false
|
||||
t.string "reset_password_token"
|
||||
t.datetime "reset_password_sent_at"
|
||||
t.datetime "remember_created_at"
|
||||
|
@ -89,6 +105,7 @@ ActiveRecord::Schema.define(version: 2021_12_22_143721) do
|
|||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["last_visited_project_id"], name: "index_users_on_last_visited_project_id"
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
t.index ["token"], name: "index_users_on_token", unique: true
|
||||
end
|
||||
|
||||
create_table "users_roles", id: false, force: :cascade do |t|
|
||||
|
@ -102,5 +119,6 @@ ActiveRecord::Schema.define(version: 2021_12_22_143721) do
|
|||
end
|
||||
|
||||
add_foreign_key "projects", "accounts"
|
||||
add_foreign_key "s3_buckets", "accounts"
|
||||
add_foreign_key "users", "projects", column: "last_visited_project_id"
|
||||
end
|
||||
|
|
|
@ -43,6 +43,21 @@ input CreateProjectInput {
|
|||
organizationName: String
|
||||
}
|
||||
|
||||
"""
|
||||
Autogenerated input type of CreateS3Bucket
|
||||
"""
|
||||
input CreateS3BucketInput {
|
||||
accessKeyId: String!
|
||||
accountId: ID!
|
||||
|
||||
"""
|
||||
A unique identifier for the client performing the mutation.
|
||||
"""
|
||||
clientMutationId: String
|
||||
name: String!
|
||||
secretAccessKey: String!
|
||||
}
|
||||
|
||||
type Invitation {
|
||||
inviteeEmail: ID!
|
||||
inviter: User!
|
||||
|
@ -93,6 +108,16 @@ type Mutation {
|
|||
input: CreateProjectInput!
|
||||
): Project!
|
||||
|
||||
"""
|
||||
Create new S3 bucket
|
||||
"""
|
||||
createS3Bucket(
|
||||
"""
|
||||
Parameters for CreateS3Bucket
|
||||
"""
|
||||
input: CreateS3BucketInput!
|
||||
): S3Bucket!
|
||||
|
||||
"""
|
||||
Invite a user to a given organization
|
||||
"""
|
||||
|
@ -185,6 +210,12 @@ enum Role {
|
|||
user
|
||||
}
|
||||
|
||||
type S3Bucket {
|
||||
accessKeyId: String!
|
||||
name: String!
|
||||
secretAccessKey: String
|
||||
}
|
||||
|
||||
type User {
|
||||
account: Account!
|
||||
avatarUrl: String
|
||||
|
|
|
@ -285,6 +285,93 @@
|
|||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "CreateS3BucketInput",
|
||||
"description": "Autogenerated input type of CreateS3Bucket",
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "clientMutationId",
|
||||
"description": "A unique identifier for the client performing the mutation.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "accessKeyId",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "secretAccessKey",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "accountId",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
|
@ -539,6 +626,39 @@
|
|||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "createS3Bucket",
|
||||
"description": "Create new S3 bucket",
|
||||
"args": [
|
||||
{
|
||||
"name": "input",
|
||||
"description": "Parameters for CreateS3Bucket",
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "CreateS3BucketInput",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "S3Bucket",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "inviteUser",
|
||||
"description": "Invite a user to a given organization",
|
||||
|
@ -1132,6 +1252,69 @@
|
|||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "S3Bucket",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "accessKeyId",
|
||||
"description": null,
|
||||
"args": [
|
||||
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"description": null,
|
||||
"args": [
|
||||
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "secretAccessKey",
|
||||
"description": null,
|
||||
"args": [
|
||||
|
||||
],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [
|
||||
|
||||
],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class S3BucketCreateServiceTest < ActiveSupport::TestCase
|
||||
test "creates an S3 bucket" do
|
||||
# Given
|
||||
name = "bucket"
|
||||
access_key_id = "access key id"
|
||||
secret_access_key = "secret access key"
|
||||
account = Account.create!(owner: Organization.create!, name: "tuist")
|
||||
|
||||
# When
|
||||
got = S3BucketCreateService.call(
|
||||
name: name,
|
||||
access_key_id: access_key_id,
|
||||
secret_access_key: secret_access_key,
|
||||
account_id: account.id
|
||||
)
|
||||
|
||||
# Then
|
||||
assert_equal name, got.name
|
||||
assert_equal access_key_id, got.access_key_id
|
||||
assert_not_equal secret_access_key, got.secret_access_key
|
||||
assert_equal account.id, got.account_id
|
||||
end
|
||||
|
||||
test "creating an S3 bucket fails when another with the same name already exists" do
|
||||
# Given
|
||||
account = Account.create!(owner: Organization.create!, name: "tuist")
|
||||
S3BucketCreateService.call(
|
||||
name: "bucket",
|
||||
access_key_id: "key id 1",
|
||||
secret_access_key: "secret access key",
|
||||
account_id: account.id
|
||||
)
|
||||
|
||||
# When/Then
|
||||
assert_raises(S3BucketCreateService::Error::DuplicatedName) do
|
||||
S3BucketCreateService.call(
|
||||
name: "bucket",
|
||||
access_key_id: "key id 2",
|
||||
secret_access_key: "secret access key",
|
||||
account_id: account.id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue