Skip to content

Commit

Permalink
Merge pull request #14489 from itisAliRH/api-key-Enhancement
Browse files Browse the repository at this point in the history
API key enhancements
  • Loading branch information
bgruening committed Oct 14, 2022
2 parents 424fc9c + 2c9783a commit 93e47e4
Show file tree
Hide file tree
Showing 25 changed files with 506 additions and 191 deletions.
71 changes: 71 additions & 0 deletions client/src/components/User/APIKey/APIKey.vue
@@ -0,0 +1,71 @@
<script setup>
import { ref } from "vue";
import svc from "./model/service";
import APIKeyItem from "./APIKeyItem";
import { getGalaxyInstance } from "app";
import LoadingSpan from "components/LoadingSpan";
const apiKey = ref(null);
const loading = ref(false);
const errorMessage = ref(null);
const createLoading = ref(false);
const currentUserId = getGalaxyInstance().user.id;
const getAPIKey = () => {
loading.value = true;
svc.getAPIKey(currentUserId)
.then((result) => (apiKey.value = result[0]))
.catch((err) => (errorMessage.value = err.message))
.finally(() => (loading.value = false));
};
const createNewAPIKey = () => {
createLoading.value = true;
svc.createNewAPIKey(currentUserId)
.then(() => getAPIKey())
.catch((err) => (errorMessage.value = err.message))
.finally(() => (createLoading.value = false));
};
getAPIKey();
</script>

<template>
<section class="api-key d-flex flex-column">
<h2 v-localize>Manage API Key</h2>

<span v-localize class="mb-2">
An API key will allow you to access via web API. Please note that this key acts as an alternate means to
access your account and should be treated with the same care as your login password.
</span>

<b-alert :show="errorMessage" dismissible fade variant="warning" @dismissed="errorMessage = null">
{{ errorMessage }}
</b-alert>

<b-alert v-if="loading" class="m-2" show variant="info">
<LoadingSpan message="Loading API keys" />
</b-alert>

<b-button
v-else-if="!loading && !apiKey"
:disabled="createLoading"
class="create-button"
variant="primary"
@click.prevent="createNewAPIKey">
<icon v-if="!createLoading" icon="plus" />
<icon v-else icon="spinner" spin />
<span v-localize>Create a new key</span>
</b-button>

<div v-else-if="apiKey" class="mx-2">
<APIKeyItem :item="apiKey" @getAPIKey="getAPIKey" />
</div>
</section>
</template>

<style scoped>
.create-button {
max-width: 10rem;
}
</style>
78 changes: 78 additions & 0 deletions client/src/components/User/APIKey/APIKeyItem.vue
@@ -0,0 +1,78 @@
<script setup>
import { ref } from "vue";
import svc from "./model/service";
import { getGalaxyInstance } from "app";
import UtcDate from "components/UtcDate";
import CopyToClipboard from "components/CopyToClipboard";
defineProps({
item: {
type: Object,
required: true,
},
});
const emit = defineEmits(["getAPIKey"]);
const currentUserId = getGalaxyInstance().user.id;
const modal = ref(null);
const hover = ref(false);
const errorMessage = ref(null);
const toggleDeleteModal = () => {
modal.value.toggle();
};
const deleteKey = () => {
svc.deleteAPIKey(currentUserId)
.then(() => emit("getAPIKey"))
.catch((err) => (errorMessage.value = err.message));
};
</script>

<template>
<b-card title="Current API key">
<div class="d-flex justify-content-between w-100">
<div class="w-100">
<b-input-group
class="w-100"
@blur="hover = false"
@focus="hover = true"
@mouseover="hover = true"
@mouseleave="hover = false">
<b-input-group-prepend>
<b-input-group-text>
<icon icon="key" />
</b-input-group-text>
</b-input-group-prepend>

<b-input
:type="hover ? 'text' : 'password'"
:value="item.key"
disabled
data-test-id="api-key-input" />

<b-input-group-append>
<b-input-group-text>
<copy-to-clipboard
message="Key was copied to clipboard"
:text="item.key"
title="Copy key" />
</b-input-group-text>
<b-button title="Delete api key" @click="toggleDeleteModal">
<icon icon="trash" />
</b-button>
</b-input-group-append>
</b-input-group>
<span class="small text-black-50">
created on
<UtcDate class="text-black-50 small" :date="item.create_time" mode="pretty" />
</span>
</div>
</div>

<b-modal ref="modal" title="Delete API key" size="md" @ok="deleteKey">
<p v-localize>Are you sure you want to delete this key?</p>
</b-modal>
</b-card>
</template>
1 change: 1 addition & 0 deletions client/src/components/User/APIKey/index.js
@@ -0,0 +1 @@
export { default as APIKey } from "./APIKey.vue";
39 changes: 39 additions & 0 deletions client/src/components/User/APIKey/model/service.js
@@ -0,0 +1,39 @@
import axios from "axios";
import { getRootFromIndexLink } from "onload";

const getUrl = (path) => getRootFromIndexLink() + path;

export async function getAPIKey(userId) {
const url = getUrl(`api/users/${userId}/api_key/detailed`);
const response = await axios.get(url);
if (response.status === 204) {
return [];
}
if (response.status !== 200) {
throw new Error("Unexpected response retrieving the API key.");
}
return [response.data];
}

export async function createNewAPIKey(userId) {
const url = getUrl(`api/users/${userId}/api_key`);
const response = await axios.post(url);
if (response.status !== 200) {
throw new Error("Create API key failure.");
}
return response.data;
}

export async function deleteAPIKey(userId) {
const url = getUrl(`api/users/${userId}/api_key`);
const response = await axios.delete(url);
if (response.status !== 204) {
throw new Error("Delete API Key failure.");
}
}

export default {
getAPIKey,
createNewAPIKey,
deleteAPIKey,
};
5 changes: 1 addition & 4 deletions client/src/components/User/UserPreferencesModel.js
Expand Up @@ -56,13 +56,10 @@ export const getUserPreferencesModel = (user_id) => {
shouldRender: !config.single_user,
},
api_key: {
title: _l("Manage API Key"),
id: "edit-preferences-api-key",
title: _l("Manage API Key"),
description: _l("Access your current API key or create a new one."),
url: `/api/users/${user_id}/api_key/inputs`,
icon: "fa-key",
submitTitle: "Create a new Key",
submitIcon: "fa-check",
},
cloud_auth: {
id: "edit-preferences-cloud-auth",
Expand Down
6 changes: 6 additions & 0 deletions client/src/entry/analysis/router.js
Expand Up @@ -52,6 +52,7 @@ import VisualizationsList from "components/Visualizations/Index";
import WorkflowExport from "components/Workflow/WorkflowExport";
import WorkflowImport from "components/Workflow/WorkflowImport";
import WorkflowList from "components/Workflow/WorkflowList";
import { APIKey } from "components/User/APIKey";
import { CloudAuth } from "components/User/CloudAuth";
import { ExternalIdentities } from "components/User/ExternalIdentities";
import { HistoryExport } from "components/HistoryExport/index";
Expand Down Expand Up @@ -287,6 +288,11 @@ export function getRouter(Galaxy) {
},
redirect: redirectAnon(),
},
{
path: "user/api_key",
component: APIKey,
redirect: redirectAnon(),
},
{
path: "user/cloud_auth",
component: CloudAuth,
Expand Down
4 changes: 2 additions & 2 deletions client/src/utils/navigation/navigation.yml
Expand Up @@ -77,8 +77,8 @@ preferences:
toolbox_filters: '#edit-preferences-toolbox-filters'
manage_api_key: '#edit-preferences-api-key'
current_email: "#user-preferences-current-email"
get_new_key: '#submit'
api_key_input: "[data-label='Current API key:'] > input"
get_new_key: '.create-button'
api_key_input: '[data-test-id="api-key-input"]'
delete_account: '#delete-account'
delete_account_input: '#name-input'
delete_account_ok_btn: '.modal-footer .btn-primary'
Expand Down
42 changes: 35 additions & 7 deletions lib/galaxy/managers/api_keys.py
@@ -1,26 +1,54 @@
from typing import (
Optional,
TYPE_CHECKING,
)

from galaxy.model import User
from galaxy.structured_app import BasicSharedApp

if TYPE_CHECKING:
from galaxy.model import APIKeys


class ApiKeyManager:
def __init__(self, app: BasicSharedApp):
self.app = app

def create_api_key(self, user) -> str:
def get_api_key(self, user: User) -> Optional["APIKeys"]:
sa_session = self.app.model.context
api_key = (
sa_session.query(self.app.model.APIKeys)
.filter_by(user_id=user.id, deleted=False)
.order_by(self.app.model.APIKeys.create_time.desc())
.first()
)
return api_key

def create_api_key(self, user: User) -> "APIKeys":
guid = self.app.security.get_new_guid()
new_key = self.app.model.APIKeys()
new_key.user_id = user.id
new_key.key = guid
sa_session = self.app.model.context
sa_session.add(new_key)
sa_session.flush()
return guid
return new_key

def get_or_create_api_key(self, user) -> str:
def get_or_create_api_key(self, user: User) -> str:
# Logic Galaxy has always used - but it would appear to have a race
# condition. Worth fixing? Would kind of need a message queue to fix
# in multiple process mode.
if user.api_keys:
key = user.api_keys[0].key
else:
key = self.create_api_key(user)
api_key = self.get_api_key(user)
key = api_key.key if api_key else self.create_api_key(user).key
return key

def delete_api_key(self, user: User) -> None:
"""Marks the current user API key as deleted."""
sa_session = self.app.model.context
# Before it was possible to create multiple API keys for the same user although they were not considered valid
# So all non-deleted keys are marked as deleted for backward compatibility
api_keys = sa_session.query(self.app.model.APIKeys).filter_by(user_id=user.id, deleted=False)
for api_key in api_keys:
api_key.deleted = True
sa_session.add(api_key)
sa_session.flush()
34 changes: 0 additions & 34 deletions lib/galaxy/managers/users.py
Expand Up @@ -12,7 +12,6 @@
from markupsafe import escape
from sqlalchemy import (
and_,
desc,
exc,
func,
true,
Expand All @@ -26,7 +25,6 @@
util,
)
from galaxy.managers import (
api_keys,
base,
deletable,
)
Expand Down Expand Up @@ -66,7 +64,6 @@ class UserManager(base.ModelManager, deletable.PurgableManagerMixin):
# most of which it may be unneccessary to have here

# TODO: incorp BaseAPIController.validate_in_users_and_groups
# TODO: incorp CreatesApiKeysMixin
# TODO: incorporate UsesFormDefinitionsMixin?
def __init__(self, app: BasicSharedApp):
self.model_class = app.model.User
Expand Down Expand Up @@ -358,15 +355,6 @@ def current_user(self, trans):
# TODO: trans
return trans.user

# ---- api keys
def create_api_key(self, user: model.User) -> str:
"""
Create and return an API key for `user`.
"""
# TODO: seems like this should return the model
# Also TODO: seems unused? drop and see what happens? -John
return api_keys.ApiKeyManager(self.app).create_api_key(user)

def user_can_do_run_as(self, user) -> bool:
run_as_users = [u for u in self.app.config.get("api_allow_run_as", "").split(",") if u]
if not run_as_users:
Expand All @@ -376,28 +364,6 @@ def user_can_do_run_as(self, user) -> bool:
can_do_run_as = user_in_run_as_users or user.bootstrap_admin_user
return can_do_run_as

# TODO: possibly move to ApiKeyManager
def valid_api_key(self, user):
"""
Return this most recent APIKey for this user or None if none have been created.
"""
query = self.session().query(model.APIKeys).filter_by(user=user).order_by(desc(model.APIKeys.create_time))
all = query.all()
if len(all):
return all[0]
return None

# TODO: possibly move to ApiKeyManager
def get_or_create_valid_api_key(self, user):
"""
Return this most recent APIKey for this user or create one if none have been
created.
"""
existing = self.valid_api_key(user)
if existing:
return existing
return self.create_api_key(self, user)

# ---- preferences
def preferences(self, user):
return {key: value for key, value in user.preferences.items()}
Expand Down
1 change: 1 addition & 0 deletions lib/galaxy/model/__init__.py
Expand Up @@ -9533,6 +9533,7 @@ class APIKeys(Base, RepresentById):
user_id = Column(Integer, ForeignKey("galaxy_user.id"), index=True)
key = Column(TrimmedString(32), index=True, unique=True)
user = relationship("User", back_populates="api_keys")
deleted = Column(Boolean, index=True, default=False)


def copy_list(lst, *args, **kwds):
Expand Down

0 comments on commit 93e47e4

Please sign in to comment.