Skip to content

Commit

Permalink
Merge pull request #39 from nebulabroadcast/enhancement/new_api_key_s…
Browse files Browse the repository at this point in the history
…tructure

API Key enhancement
  • Loading branch information
martastain authored Nov 17, 2023
2 parents 8e38150 + de179f1 commit f053368
Show file tree
Hide file tree
Showing 15 changed files with 137 additions and 52 deletions.
13 changes: 6 additions & 7 deletions backend/api/browse.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class BrowseResponseModel(ResponseModel):


def sanitize_value(value: Any) -> Any:
if type(value) is str:
if isinstance(value, str):
value = value.replace("'", "''")
return str(value)

Expand All @@ -116,7 +116,7 @@ def build_conditions(conditions: list[ConditionModel]) -> list[str]:
), f"Invalid meta key {condition.key}"
condition.value = normalize_meta(condition.key, condition.value)
if condition.operator in ["IN", "NOT IN"]:
assert type(condition.value) is list, "Value must be a list"
assert isinstance(condition.value, list), "Value must be a list"
values = sql_list([sanitize_value(v) for v in condition.value], t="str")
cond_list.append(f"meta->>'{condition.key}' {condition.operator} {values}")
elif condition.operator in ["IS NULL", "IS NOT NULL"]:
Expand Down Expand Up @@ -185,14 +185,14 @@ def build_query(

if request.view is None:
try:
request.view = nebula.settings.views[0]
request.view = nebula.settings.views[0].id
except IndexError as e:
raise NebulaException("No views defined") from e

# Process views

if request.view is not None and not request.ignore_view_conditions:
assert type(request.view) is int, "View must be an integer"
assert isinstance(request.view, int), "View must be an integer"
if (view := nebula.settings.get_view(request.view)) is not None:
if view.folders:
cond_list.append(f"id_folder IN {sql_list(view.folders)}")
Expand Down Expand Up @@ -222,7 +222,7 @@ def build_query(
c2 = f"meta->'assignees' @> '[{user.id}]'::JSONB"
cond_list.append(f"({c1} OR {c2})")

if (can_view := user["can/asset_view"]) and type(can_view) is list:
if (can_view := user["can/asset_view"]) and isinstance(can_view, list):
cond_list.append(f"id_folder IN {sql_list(can_view)}")

# Build conditions
Expand Down Expand Up @@ -260,10 +260,9 @@ async def handle(
request: BrowseRequestModel,
user: CurrentUser,
) -> BrowseResponseModel:

columns: list[str] = ["title", "duration"]
if request.view is not None and not request.columns:
assert type(request.view) is int, "View must be an integer"
assert isinstance(request.view, int), "View must be an integer"
if (view := nebula.settings.get_view(request.view)) is not None:
if view.columns is not None:
columns = view.columns
Expand Down
2 changes: 1 addition & 1 deletion backend/api/scheduler/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ async def create_new_event(
if event_data.items:
for item_data in event_data.items:
if item_data.get("id"):
assert type(item_data["id"]) == int, "Invalid item ID"
assert isinstance(item_data["id"], int), "Invalid item ID"
item = await nebula.Item.load(item_data["id"], connection=conn)
else:
item = nebula.Item(connection=conn)
Expand Down
4 changes: 2 additions & 2 deletions backend/api/set.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ async def can_modify_object(obj, user: nebula.User):
acl = user.get("can/asset_edit", False)
if not acl:
raise nebula.ForbiddenException("You are not allowed to edit assets")
elif type(acl) == list and obj["id_folder"] not in acl:
elif isinstance(acl, list) and obj["id_folder"] not in acl:
raise nebula.ForbiddenException(
"You are not allowed to edit assets in this folder"
)
Expand All @@ -130,7 +130,7 @@ async def can_modify_object(obj, user: nebula.User):
acl = user.get("can/scheduler_edit", False)
if not acl:
raise nebula.ForbiddenException("You are not allowed to edit schedule")
elif type(acl) == list and obj["id_channel"] not in acl:
elif isinstance(acl, list) and obj["id_channel"] not in acl:
raise nebula.ForbiddenException(
"You are not allowed to edit schedule for this channel"
)
Expand Down
8 changes: 6 additions & 2 deletions backend/api/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ async def handle(self, user: CurrentUser) -> UserListResponseModel:
async for row in nebula.db.iterate(query):
meta = {}
for key, value in row["meta"].items():
if key == "password":
if key == "api_key_preview":
continue
if key == "api_key":
meta[key] = row["meta"].get("api_key_preview", "*****")
elif key == "password":
continue
elif key.startswith("can/"):
meta[key.replace("can/", "can_")] = value
Expand All @@ -76,7 +80,7 @@ class SaveUserRequest(APIRequest):
title: str = "Save user data"
responses = [204, 201]

async def handle(self, user: CurrentUser, payload: UserModel) -> None:
async def handle(self, user: CurrentUser, payload: UserModel) -> Response:
new_user = payload.id is None

if not user.is_admin:
Expand Down
2 changes: 1 addition & 1 deletion backend/nebula/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def __init__(

if log is True or self.log:
logger.error(f"EXCEPTION: {self.status} {self.detail}", user=user_name)
elif type(log) is str:
elif isinstance(log, str):
logger.error(f"EXCEPTION: {self.status} {log}", user=user_name)

super().__init__(self.detail)
Expand Down
6 changes: 3 additions & 3 deletions backend/nebula/metadata/normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def is_serializable(value: Any) -> bool:
This is used to check if a value can be stored in the database.
"""
if type(value) in (str, int, float, bool, dict, list, tuple):
if isinstance(value, (str, int, float, bool, dict, list, tuple)):
return True
return False

Expand Down Expand Up @@ -85,11 +85,11 @@ def normalize_meta(key: str, value: Any) -> Any:
return value

case MetaClass.FRACTION:
assert type(value) is str, f"{key} must be a string. is {type(value)}"
assert isinstance(value, str), f"{key} must be a string. is {type(value)}"
return value

case MetaClass.SELECT:
assert type(value) is str, f"{key} must be a string. is {type(value)}"
assert isinstance(value, str), f"{key} must be a string. is {type(value)}"
return str(value)

case MetaClass.LIST:
Expand Down
1 change: 1 addition & 0 deletions backend/nebula/objects/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def set_password(self, password: str) -> None:

def set_api_key(self, api_key: str) -> None:
self.meta["api_key"] = hash_password(api_key)
self.meta["api_key_preview"] = api_key[:4] + "*******" + api_key[-4:]

def can(
self,
Expand Down
16 changes: 12 additions & 4 deletions backend/server/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ async def access_token(authorization: str = Header(None)) -> str | None:
AccessToken = Annotated[str | None, Depends(access_token)]


async def api_key(x_api_key: str | None = Header(None)) -> str | None:
"""Return the API key provided in the request headers."""
return x_api_key


ApiKey = Annotated[str | None, Depends(api_key)]


async def request_initiator(x_client_id: str | None = Header(None)) -> str | None:
"""Return the client ID of the request initiator."""
return x_client_id
Expand All @@ -40,15 +48,15 @@ async def current_user_query(token: str = Query(None)) -> nebula.User:

async def current_user(
request: Request,
access_token: str | None = Depends(access_token),
x_api_key: str | None = Header(None),
access_token: AccessToken,
api_key: ApiKey,
) -> nebula.User:
"""Return the currently logged-in user"""
if access_token is None:
if x_api_key is None:
if api_key is None:
raise nebula.UnauthorizedException("No access token provided")
try:
return await nebula.User.by_api_key(x_api_key)
return await nebula.User.by_api_key(api_key)
except nebula.NotFoundException as e:
raise nebula.UnauthorizedException("Invalid API key") from e

Expand Down
4 changes: 2 additions & 2 deletions backend/server/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def install_endpoints(app: fastapi.FastAPI):
additional_params["response_model"] = endpoint.response_model
additional_params["response_model_exclude_none"] = endpoint.exclude_none

if type(endpoint.__doc__) is str:
if isinstance(endpoint.__doc__, str):
docstring = "\n".join([r.strip() for r in endpoint.__doc__.split("\n")])
else:
docstring = ""
Expand All @@ -93,7 +93,7 @@ def install_endpoints(app: fastapi.FastAPI):
server_context.scoped_endpoints.append(
ScopedEndpoint(
endpoint=endpoint.name,
title=endpoint.title,
title=endpoint.title or endpoint.name,
scopes=endpoint.scopes,
)
)
Expand Down
2 changes: 1 addition & 1 deletion backend/server/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ def parse_access_token(authorization: str) -> str | None:
string or None if the input value does not match
the expected format (64 bytes string)
"""
if (not authorization) or type(authorization) != str:
if (not authorization) or not isinstance(authorization, str):
return None
try:
ttype, token = authorization.split()
Expand Down
2 changes: 1 addition & 1 deletion backend/server/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ async def receive(self):
data = await self.sock.receive_text()
try:
message = json_loads(data)
assert type(message) is dict
assert isinstance(message, dict)
assert "topic" in message
except AssertionError:
return None
Expand Down
7 changes: 4 additions & 3 deletions backend/setup/settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from typing import Any
from urllib.parse import urlparse

import httpx
Expand Down Expand Up @@ -51,9 +52,9 @@ def load_overrides():
override = getattr(mod, key.upper())
log.info(f"Found overrides for {key}")

if type(override) == dict and type(TEMPLATE[key]) == dict:
if isinstance(override, dict) and isinstance(TEMPLATE[key], dict):
TEMPLATE[key].update(override)
elif type(override) == list and type(TEMPLATE[key]) == list:
elif isinstance(override, list) and isinstance(TEMPLATE[key], list):
TEMPLATE[key] = override
else:
log.error(f"Invalid settings override: {spath}")
Expand All @@ -64,7 +65,7 @@ async def setup_settings(db):
load_overrides()

log.info("Applying system settings")
settings: dict[str, any] = {}
settings: dict[str, Any] = {}

# Nebula 5 compat
redis_url = urlparse(config.redis)
Expand Down
83 changes: 62 additions & 21 deletions frontend/src/pages/UsersPage/ApiKeyPicker.jsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,47 @@
import { toast } from 'react-toastify'
import { v4 as uuidv4 } from 'uuid'
import { useState, useMemo } from 'react'
import styled from 'styled-components'

import { Dialog, InputText, Button } from '/src/components'

const ApiKeyPicker = ({ setApiKey }) => {
const SubRow = styled.div`
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
`

const createApiKey = () => {
const prefix = 'nb'
const segmentCount = 4
const segmentLength = 12
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_'
const segments = []

for (let i = 0; i < segmentCount; i++) {
let segment = ''
for (let j = 0; j < segmentLength; j++) {
const randomIndex = Math.floor(Math.random() * characters.length)
segment += characters[randomIndex]
}
segments.push(segment)
}

return `${prefix}.${segments.join('.')}`
}

const ApiKeyPicker = ({ setApiKey, apiKeyPreview }) => {
const [dialogVisible, setDialogVisible] = useState(false)

const dialog = useMemo(() => {
if (!dialogVisible) return null

const newKey = uuidv4()
const newKey = createApiKey()
return (
<Dialog
onHide={() => setDialogVisible(false)}
style={{ width: 550 }}
header="Create API key"
footer={
<>
Expand All @@ -35,42 +63,55 @@ const ApiKeyPicker = ({ setApiKey }) => {
>
<p>
This API key will be used to authenticate your requests to the server.
</p>
<p>
Copy it to your clipboard and store it in a safe place. You will not
be able to retrieve it later.
</p>

<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<InputText value={newKey} readOnly style={{ flexGrow: 1 }} />
<SubRow>
<InputText
value={newKey}
readOnly
style={{
flexGrow: 1,
fontFamily: 'monospace',
fontStyle: 'normal',
textAlign: 'center',
}}
onClick={(e) => e.target.select()}
/>
<Button
icon="content_copy"
label="Copy to clipboard"
tooltip="Copy to clipboard"
onClick={() => {
navigator.clipboard.writeText(newKey)
toast.success('Copied to clipboard')
}}
/>
</div>
</SubRow>
</Dialog>
)
}, [dialogVisible])

return (
<>
<SubRow>
<InputText
value={apiKeyPreview}
readOnly
style={{
flexGrow: 1,
fontFamily: 'monospace',
fontStyle: 'normal',
textAlign: 'center',
}}
/>
<Button
icon="key"
label="Create API key"
onClick={() => setDialogVisible(true)}
/>
</SubRow>
{dialog}
<Button
icon="key"
label="Create API key"
onClick={() => setDialogVisible(true)}
/>
</>
)
}
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/pages/UsersPage/UserList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ const UserList = ({ onSelect, currentId, reloadTrigger }) => {
nebula
.request('user_list')
.then((res) => {
setUsers(res.data.users)
setUsers(
res.data.users.map((user) => ({
...user,
password: undefined,
api_key: undefined,
api_key_preview: user.api_key,
}))
)
})
.finally(() => setLoading(false))
}, [reloadTrigger])
Expand Down
Loading

0 comments on commit f053368

Please sign in to comment.