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:
Marek Fořt 2022-02-02 21:11:31 +01:00 committed by GitHub
parent d0c184904d
commit 61d8e1b894
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 775 additions and 39 deletions

View File

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

28
projects/cloud/.vscode/settings.json vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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 />} />

View File

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

View File

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

View File

@ -154,7 +154,7 @@ const OrganizationPage = observer(() => {
onChange={(newValue) => {
organizationPageStore.inviteeEmail = newValue;
}}
></TextField>
/>
<Button
primary
onClick={() => {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
mutation CreateS3Bucket($input: CreateS3BucketInput!) {
createS3Bucket(input: $input) {
name
accessKeyId
secretAccessKey
}
}

View File

@ -1,8 +1,6 @@
query Me {
me {
id
email
avatarUrl
...UserBasicInfo
lastVisitedProject {
slug
}

View File

@ -1,6 +1,7 @@
query Project($name: String!, $accountName: String!) {
project(name: $name, accountName: $accountName) {
account {
id
owner {
... on Organization {
id

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]

View File

@ -0,0 +1 @@
09a0926fc36e14a27bcc96e7f9323d05

View File

@ -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==

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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