Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Db interfaces #1

Merged
merged 10 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,6 @@ bundles/
vendor/pkg/
pyenv
Vagrantfile

# vscode stuff
.vscode/**/*
142 changes: 142 additions & 0 deletions app/utils/key_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import redis
import pymongo
import secrets
import string
import os
import urllib


class key_handler:
def __init__(self, testing: bool = False):
if not testing:
# Needs to be escaped if necessary
mongo_user = urllib.parse.quote_plus(os.environ.get("MONGOUSER"))
mongo_password = urllib.parse.quote_plus(os.environ.get("MONGOPASSWORD"))

# Set up required endpoints. The
mongo_client = pymongo.MongoClient(
"mongodb://%s:%s@mongo:27017/" % (mongo_user, mongo_password)
)
redis_client = redis.StrictRedis(host="redis", port=6379, db=0)
self.setup(mongo_client, redis_client)

def setup(self, mongo_client: pymongo.MongoClient, redis_client: redis.Redis):
self.redis_client = redis_client
self.mongo_client = mongo_client
self.db = mongo_client["gateway"]
self.keyCollection = self.db["apikeys"]
keyindices = self.keyCollection.index_information()
# Make sure, that key is an index (avoids duplicates);
if not "key" in keyindices:
self.keyCollection.create_index("key")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add "unique" constraint here?

self.keyCollection.create_index("key")

=>

self.keyCollection.create_index("key", "unique")

Otherwise I think it's just an index.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes. Thanks

self.userCollection = self.db["users"]
# Make sure, that username is an index (avoids duplicates when creating keys, which automatically adds a user if necessary);
userindices = self.userCollection.index_information()
if not "username" in userindices:
self.userCollection.create_index("username")
self.userCollection.find
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this line OK? Seems to be cut off at the middle?


def generate_api_key(self, length: int = 64):
"""
Function to generate an API key.

Parameters:
- length (int, optional): Length of the generated API key. Defaults to 64.

Returns:
- str: The generated API key.
"""
alphabet = string.ascii_letters + string.digits
api_key = "".join(secrets.choice(alphabet) for _ in range(length))
return api_key

def build_new_key_object(self, key: string, name: string):
"""
Function to create a new key object.

Parameters:
- key (str): The key value.
- name (str): The name associated with the key.

Returns:
- dict: A dictionary representing the key object with "active" status, key, and name.
"""
return {"active": True, "key": key, "name": name}

def check_key(self, key: string):
"""
Function to check if a key currently exists

Parameters:
- key (str): The key to check.

Returns:
- bool: True if the key exists
"""
return self.redis_client.sismember("keys", key)

def delete_key(self, key: string, user: string):
"""
Function to delete an existing key

Parameters:
- key (str): The key to check.
- user (str): The user that requests this deletion

"""
updated_user = self.userCollection.find_one_and_update(
{"username": user, "keys": {"$elemMatch": {"$eq": key}}},
{"$pull": {"keys": key}},
)
if not updated_user == None:
# We found, and updated the user, so we can remove the key
# removal should be instantaneous
self.keyCollection.delete_one({"key": key})
self.redis_client.srem("keys", key)

def set_key_activity(self, key: string, user: string, active: bool):
"""
Function to set the activity of a key
Copy link
Contributor

@ruokolt ruokolt Nov 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion:

"Function to set the activity of a key associated with a user."

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function to set whether a key is active or not.
The key has to be owned by the user indicated.

Essentially, this is a check, whether that user is allowed to modify the key. If the key is not owned by the user this is a clear "no".


Parameters:
- key (str): The key to check.
- user (str): The user that requests this deletion
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion:

"The user associated with the key."

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep it as is, since this is kind of an auth-check here. and, yes, it needs to be the user associated with the key, but the input could also be a different user.

- active (bool): whether to activate or deactivate the key
"""
user_has_key = self.userCollection.find_one(
{"username": user, "keys": {"$elemMatch": {"$eq": key}}}
)
if not user_has_key == None:
# the requesting user has access to this key
self.keyCollection.update_one({"key": key}, {"$set": {"active": active}})
if active:
self.redis_client.sadd("keys", key)
else:
self.redis_client.srem("keys", key)

def create_key(self, user: string, name: string):
"""
Generates a unique API key and associates it with a specified user.

Args:
- user: Username of the user to whom the API key will be associated.
- name: Name or label for the API key.
- userCollection: Collection object representing the 'users' collection in MongoDB (default: global variable).
- keyCollection: Collection object representing the 'apikeys' collection in MongoDB (default: global variable).

Returns:
- api_key: The generated unique API key associated with the user.
"""
keyCreated = False
api_key = ""
while not keyCreated:
api_key = self.generate_api_key()
found = self.keyCollection.find_one({"key": api_key})
if found == None:
self.keyCollection.insert_one(self.build_new_key_object(api_key, name))
self.userCollection.update_one(
{"username": user}, {"$addToSet": {"keys": api_key}}, upsert=True
)
self.redis_client.sadd("keys", api_key)
keyCreated = True
return api_key
83 changes: 83 additions & 0 deletions app/utils/logging_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import pymongo
from datetime import datetime
import os
import urllib


# Needs to be escaped if necessary
class logging_handler:
def __init__(self, testing=False):
if not testing:
mongo_user = urllib.parse.quote_plus(os.environ.get("MONGOUSER"))
mongo_password = urllib.parse.quote_plus(os.environ.get("MONGOPASSWORD"))
# Set up required endpoints.
mongo_client = pymongo.MongoClient(
"mongodb://%s:%s@mongo:27017/" % (mongo_user, mongo_password)
)
self.setup(mongo_client)

def setup(self, mongo_client):
self.db = mongo_client["gateway"]
self.logCollection = self.db["logs"]
self.userCollection = self.db["users"]

def create_log_entry(self, tokencount, model, source, sourcetype="apikey"):
"""
Function to create a log entry.

Parameters:
- tokencount (int): The count of tokens.
- model (str): The model related to the log entry.
- source (str): The source of the log entry.
Copy link
Contributor

@ruokolt ruokolt Nov 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be expanded a bit, I had to think for a second what the source was although it's clearer when combined with the sourcetype parameter. Like

The source of the user's request.

Copy link
Member Author

@tpfau tpfau Nov 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed:
What about:

- source (str): The source that authorized the request that is being logged. This could be a user name or an apikey.
- sourcetype (str): Specification of what kind of source authorized the request that is being logged (either 'apikey' or 'user').

- sourcetype (str): The type of source either apikey or user..

Returns:
- dict: A dictionary representing the log entry with timestamp.
"""
return {
"tokencount": tokencount,
"model": model,
"source": source,
"sourcetype": sourcetype,
"timestamp": datetime.utcnow(), # Current timestamp in UTC
}

def log_usage_for_key(self, tokencount, model, key):
"""
Function to log usage for a specific key.

Parameters:
- tokencount (int): The count of tokens used.
- model (str): The model associated with the usage.
- key (str): The key for which the usage is logged.
"""
logEntry = self.create_log_entry(tokencount, model, key)
self.logCollection.insert_one(logEntry)

def log_usage_for_user(self, tokencount, model, user):
"""
Function to log usage for a specific user.

Parameters:
- tokencount (int): The count of tokens used.
- model (str): The model associated with the usage.
- user (str): The user for which the usage is logged.
"""
logEntry = self.create_log_entry(tokencount, model, user, "user")
self.logCollection.insert_one(logEntry)

def get_usage_for_user(self, username):
# Needs to be implemented
Copy link
Contributor

@ruokolt ruokolt Nov 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fun detail, if I remember correctly the Pythonic way of marking this is to use the inbuilt notImplementedError

raise NotImplementedError

Saves you a whopping one line cause the explaining comment can be removed.

pass

def get_usage_for_key(self, key):
# TODO: needs to be implemented
pass

def get_usage_for_model(self, model):
# TODO: needs to be implemented
pass

def get_usage_for_timerange(self, start, end):
# TODO: needs to be implemented
pass
68 changes: 68 additions & 0 deletions app/utils/redis_updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import redis
import pymongo
import schedule
import time
import urllib
import os

# Needs to be escaped if necessary
mongo_user = urllib.parse.quote_plus(os.environ.get("MONGOUSER"))
mongo_password = urllib.parse.quote_plus(os.environ.get("MONGOPASSWORD"))


class RedisUpdater:
def __init__(self):
"""
Initializes Redis and MongoDB connections.
"""
# Redis connection
self.redis_client = redis.StrictRedis(host="redis", port=6379, db=0)
self.entries = [] # Placeholder for entries fetched from MongoDB

# MongoDB connection
self.mongo_client = mongo_client = pymongo.MongoClient(
"mongodb://%s:%s@mongo:27017/" % (mongo_user, mongo_password)
)
self.db = mongo_client["gateway"]
self.keyCollection = self.db["apikeys"]

def update_redis(self):
"""
Updates Redis with API keys fetched from MongoDB.
"""
# Fetch entries from MongoDB
self.fetch_entries_from_mongodb()
# Clean up the current keys, and then add the new ones.
# optimally, this would be done in an atomic call, but we will have to
# see how often someone
self.redis_client.delete("keys")
# Update Redis with fetched entries
self.redis_client.sadd("keys", *self.entries)

def fetch_entries_from_mongodb(self):
"""
Retrieves active API keys from MongoDB and stores them in self.entries.
"""
# Retrieve entries from MongoDB
currentKeys = self.collection.find({"active": True})
self.entries = [
entry["APIKEY"] for entry in self.collection.find({}, {"key": 1})
]

def start_scheduler(self):
"""
Initiates a scheduler to run update_redis every 15 minutes continuously.
"""
# Schedule the update every 15 minutes
schedule.every(15).minutes.do(self.update_redis)

# Run the scheduler continuously
while True:
schedule.run_pending()
# Only do this every 60 seconds - thats often enough.
time.sleep(60)


# Create an instance of the RedisUpdater and start the scheduler
updater = RedisUpdater()
updater.start_scheduler()
77 changes: 77 additions & 0 deletions app/utils/test_key_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from pytest_mock_resources import create_redis_fixture
from pytest_mock_resources import create_mongo_fixture
from key_handler import key_handler
import redis
import pymongo

redis = create_redis_fixture()
mongo = create_mongo_fixture()


def test_check_key(redis, mongo):
handler = key_handler(True)
handler.setup(mongo, redis)
keys = ["ABC", "DEF"]
redis.sadd("keys", *keys)
assert handler.check_key("ABC") == True
assert handler.check_key("DEF") == True
assert handler.check_key("BCE") == False


def test_create_key(redis, mongo):
handler = key_handler(True)
handler.setup(mongo, redis)
newKey = handler.create_key("NewUser", "NewKey")
db = mongo["gateway"]
userCollection = db["users"]
keyCollection = db["apikeys"]
assert userCollection.count_documents({}) == 1
user = userCollection.find_one({})
assert user["username"] == "NewUser"
assert len(user["keys"]) == 1
assert keyCollection.count_documents({}) == 1
assert handler.check_key(newKey) == True


def test_delete_key(redis, mongo):
handler = key_handler(True)
handler.setup(mongo, redis)
newKey = handler.create_key("NewUser", "NewKey")
db = mongo["gateway"]
userCollection = db["users"]
keyCollection = db["apikeys"]
assert userCollection.count_documents({}) == 1
assert keyCollection.count_documents({}) == 1
assert handler.check_key(newKey) == True
handler.delete_key(newKey, "NewUser")
user = userCollection.find_one({})
assert user["username"] == "NewUser"
assert len(user["keys"]) == 0
assert keyCollection.count_documents({}) == 0
assert handler.check_key(newKey) == False


def test_set_key_activity(redis, mongo):
handler = key_handler(True)
handler.setup(mongo, redis)
# Create key
newKey = handler.create_key("NewUser", "NewKey")
db = mongo["gateway"]
userCollection = db["users"]
keyCollection = db["apikeys"]
# Make sure key is valid at start
assert handler.check_key(newKey) == True
# deactivate key
handler.set_key_activity(newKey, "NewUser", False)
key_data = keyCollection.find_one({"key": newKey})
assert key_data["active"] == False
# Ensure key still exists
user = userCollection.find_one({"username": "NewUser"})
assert len(user["keys"]) == 1
# and key is inactive
assert handler.check_key(newKey) == False
# reactivate and test that the key is now active again
handler.set_key_activity(newKey, "NewUser", True)
key_data = keyCollection.find_one({"key": newKey})
assert key_data["active"] == True
assert handler.check_key(newKey) == True
Loading