feat: basic RBAC support

This commit is contained in:
Timothy J. Baek 2023-11-18 21:41:43 -08:00
parent 921eef03b3
commit 8547b7807d
13 changed files with 266 additions and 44 deletions

View file

@ -8,7 +8,7 @@ import json
from apps.web.models.users import Users from apps.web.models.users import Users
from constants import ERROR_MESSAGES from constants import ERROR_MESSAGES
from utils import extract_token_from_auth_header from utils.utils import extract_token_from_auth_header
from config import OLLAMA_API_BASE_URL, OLLAMA_WEBUI_AUTH from config import OLLAMA_API_BASE_URL, OLLAMA_WEBUI_AUTH
app = Flask(__name__) app = Flask(__name__)
@ -25,24 +25,37 @@ TARGET_SERVER_URL = OLLAMA_API_BASE_URL
def proxy(path): def proxy(path):
# Combine the base URL of the target server with the requested path # Combine the base URL of the target server with the requested path
target_url = f"{TARGET_SERVER_URL}/{path}" target_url = f"{TARGET_SERVER_URL}/{path}"
print(target_url) print(path)
# Get data from the original request # Get data from the original request
data = request.get_data() data = request.get_data()
headers = dict(request.headers) headers = dict(request.headers)
# Basic RBAC support
if OLLAMA_WEBUI_AUTH: if OLLAMA_WEBUI_AUTH:
if "Authorization" in headers: if "Authorization" in headers:
token = extract_token_from_auth_header(headers["Authorization"]) token = extract_token_from_auth_header(headers["Authorization"])
user = Users.get_user_by_token(token) user = Users.get_user_by_token(token)
if user: if user:
print(user) # Only user and admin roles can access
pass if user.role in ["user", "admin"]:
if path in ["pull", "delete", "push", "copy", "create"]:
# Only admin role can perform actions above
if user.role == "admin":
pass
else:
return (
jsonify({"detail": ERROR_MESSAGES.ACCESS_PROHIBITED}),
401,
)
else:
pass
else:
return jsonify({"detail": ERROR_MESSAGES.ACCESS_PROHIBITED}), 401
else: else:
return jsonify({"detail": ERROR_MESSAGES.UNAUTHORIZED}), 401 return jsonify({"detail": ERROR_MESSAGES.UNAUTHORIZED}), 401
else: else:
return jsonify({"detail": ERROR_MESSAGES.UNAUTHORIZED}), 401 return jsonify({"detail": ERROR_MESSAGES.UNAUTHORIZED}), 401
else: else:
pass pass

View file

@ -5,7 +5,7 @@ import uuid
from apps.web.models.users import UserModel, Users from apps.web.models.users import UserModel, Users
from utils import ( from utils.utils import (
verify_password, verify_password,
get_password_hash, get_password_hash,
bearer_scheme, bearer_scheme,
@ -43,6 +43,7 @@ class UserResponse(BaseModel):
email: str email: str
name: str name: str
role: str role: str
profile_image_url: str
class SigninResponse(Token, UserResponse): class SigninResponse(Token, UserResponse):
@ -66,7 +67,7 @@ class AuthsTable:
self.table = db.auths self.table = db.auths
def insert_new_auth( def insert_new_auth(
self, email: str, password: str, name: str, role: str = "user" self, email: str, password: str, name: str, role: str = "pending"
) -> Optional[UserModel]: ) -> Optional[UserModel]:
print("insert_new_auth") print("insert_new_auth")

View file

@ -3,7 +3,9 @@ from typing import List, Union, Optional
from pymongo import ReturnDocument from pymongo import ReturnDocument
import time import time
from utils import decode_token from utils.utils import decode_token
from utils.misc import get_gravatar_url
from config import DB from config import DB
#################### ####################
@ -15,7 +17,8 @@ class UserModel(BaseModel):
id: str id: str
name: str name: str
email: str email: str
role: str = "user" role: str = "pending"
profile_image_url: str = "/user.png"
created_at: int # timestamp in epoch created_at: int # timestamp in epoch
@ -30,7 +33,7 @@ class UsersTable:
self.table = db.users self.table = db.users
def insert_new_user( def insert_new_user(
self, id: str, name: str, email: str, role: str = "user" self, id: str, name: str, email: str, role: str = "pending"
) -> Optional[UserModel]: ) -> Optional[UserModel]:
user = UserModel( user = UserModel(
**{ **{
@ -38,6 +41,7 @@ class UsersTable:
"name": name, "name": name,
"email": email, "email": email,
"role": role, "role": role,
"profile_image_url": get_gravatar_url(email),
"created_at": int(time.time()), "created_at": int(time.time()),
} }
) )

View file

@ -9,12 +9,14 @@ import time
import uuid import uuid
from constants import ERROR_MESSAGES from constants import ERROR_MESSAGES
from utils import ( from utils.utils import (
get_password_hash, get_password_hash,
bearer_scheme, bearer_scheme,
create_token, create_token,
) )
from utils.misc import get_gravatar_url
from apps.web.models.auths import ( from apps.web.models.auths import (
SigninForm, SigninForm,
SignupForm, SignupForm,
@ -45,10 +47,12 @@ async def get_session_user(cred=Depends(bearer_scheme)):
"email": user.email, "email": user.email,
"name": user.name, "name": user.name,
"role": user.role, "role": user.role,
"profile_image_url": user.profile_image_url,
} }
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.INVALID_TOKEN,
) )
@ -70,9 +74,10 @@ async def signin(form_data: SigninForm):
"email": user.email, "email": user.email,
"name": user.name, "name": user.name,
"role": user.role, "role": user.role,
"profile_image_url": user.profile_image_url,
} }
else: else:
raise HTTPException(400, detail=ERROR_MESSAGES.DEFAULT()) raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
############################ ############################
@ -98,6 +103,7 @@ async def signup(form_data: SignupForm):
"email": user.email, "email": user.email,
"name": user.name, "name": user.name,
"role": user.role, "role": user.role,
"profile_image_url": user.profile_image_url,
} }
else: else:
raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err))

View file

@ -7,7 +7,12 @@ class MESSAGES(str, Enum):
class ERROR_MESSAGES(str, Enum): class ERROR_MESSAGES(str, Enum):
DEFAULT = lambda err="": f"Something went wrong :/\n{err if err else ''}" DEFAULT = lambda err="": f"Something went wrong :/\n{err if err else ''}"
INVALID_TOKEN = (
"Your session has expired or the token is invalid. Please sign in again."
)
INVALID_CRED = "The email or password provided is incorrect. Please check for typos and try logging in again."
UNAUTHORIZED = "401 Unauthorized" UNAUTHORIZED = "401 Unauthorized"
ACCESS_PROHIBITED = "You do not have permission to access this resource. Please contact your administrator for assistance."
NOT_FOUND = "We could not find what you're looking for :/" NOT_FOUND = "We could not find what you're looking for :/"
USER_NOT_FOUND = "We could not find what you're looking for :/" USER_NOT_FOUND = "We could not find what you're looking for :/"
MALICIOUS = "Unusual activities detected, please try again in a few minutes." MALICIOUS = "Unusual activities detected, please try again in a few minutes."

15
backend/utils/misc.py Normal file
View file

@ -0,0 +1,15 @@
import hashlib
def get_gravatar_url(email):
# Trim leading and trailing whitespace from
# an email address and force all characters
# to lower case
address = str(email).strip().lower()
# Create a SHA256 hash of the final string
hash_object = hashlib.sha256(address.encode())
hash_hex = hash_object.hexdigest()
# Grab the actual image URL
return f"https://www.gravatar.com/avatar/{hash_hex}"

View file

@ -149,6 +149,10 @@
if (data.error) { if (data.error) {
throw data.error; throw data.error;
} }
if (data.detail) {
throw data.detail;
}
if (data.status) { if (data.status) {
if (!data.status.includes('downloading')) { if (!data.status.includes('downloading')) {
toast.success(data.status); toast.success(data.status);
@ -206,6 +210,10 @@
if (data.error) { if (data.error) {
throw data.error; throw data.error;
} }
if (data.detail) {
throw data.detail;
}
if (data.status) { if (data.status) {
} }
} else { } else {

View file

@ -388,17 +388,17 @@
{#if $user !== undefined} {#if $user !== undefined}
<button <button
class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition" class=" flex rounded-md py-3 px-3.5 w-full hover:bg-gray-900 transition"
on:focus={() => { on:click={() => {
showDropdown = true; showDropdown = !showDropdown;
}} }}
on:focusout={() => { on:focusout={() => {
setTimeout(() => { setTimeout(() => {
showDropdown = false; showDropdown = false;
}, 100); }, 150);
}} }}
> >
<div class=" self-center mr-3"> <div class=" self-center mr-3">
<img src="/user.png" class=" max-w-[30px] object-cover rounded-full" /> <img src={$user.profile_image_url} class=" max-w-[30px] object-cover rounded-full" />
</div> </div>
<div class=" self-center font-semibold">{$user.name}</div> <div class=" self-center font-semibold">{$user.name}</div>
</button> </button>
@ -406,7 +406,7 @@
{#if showDropdown} {#if showDropdown}
<div <div
id="dropdownDots" id="dropdownDots"
class="absolute z-10 bottom-[4.5rem] rounded-lg shadow w-[240px] bg-gray-900" class="absolute z-10 bottom-[70px] 4.5rem rounded-lg shadow w-[240px] bg-gray-900"
> >
<div class="py-2 w-full"> <div class="py-2 w-full">
<button <button
@ -440,14 +440,14 @@
</button> </button>
</div> </div>
<hr class=" dark:border-gray-700 m-0 p-0" /> <hr class=" border-gray-700 m-0 p-0" />
<div class="py-2 w-full"> <div class="py-2 w-full">
<button <button
class="flex py-2.5 px-3.5 w-full hover:bg-gray-800 transition" class="flex py-2.5 px-3.5 w-full hover:bg-gray-800 transition"
on:click={() => { on:click={() => {
localStorage.removeItem('token'); localStorage.removeItem('token');
location.href = '/'; location.href = '/auth';
}} }}
> >
<div class=" self-center mr-3"> <div class=" self-center mr-3">

View file

@ -1,12 +1,19 @@
<script> <script>
import { config, user } from '$lib/stores'; import { config, user } from '$lib/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount, tick } from 'svelte';
if ($config && $config.auth && $user === undefined) { let loaded = false;
goto('/auth'); onMount(async () => {
} if ($config && $config.auth && $user === undefined) {
await goto('/auth');
}
await tick();
loaded = true;
});
</script> </script>
{#if $config !== undefined} {#if loaded}
<slot /> <slot />
{/if} {/if}

View file

@ -16,7 +16,7 @@
import Navbar from '$lib/components/layout/Navbar.svelte'; import Navbar from '$lib/components/layout/Navbar.svelte';
import SettingsModal from '$lib/components/chat/SettingsModal.svelte'; import SettingsModal from '$lib/components/chat/SettingsModal.svelte';
import Suggestions from '$lib/components/chat/Suggestions.svelte'; import Suggestions from '$lib/components/chat/Suggestions.svelte';
import { user } from '$lib/stores'; import { config, user } from '$lib/stores';
let API_BASE_URL = BUILD_TIME_API_BASE_URL; let API_BASE_URL = BUILD_TIME_API_BASE_URL;
let db; let db;
@ -1224,14 +1224,27 @@
<div class="flex justify-between px-5 mb-3 max-w-3xl mx-auto rounded-lg group"> <div class="flex justify-between px-5 mb-3 max-w-3xl mx-auto rounded-lg group">
<div class=" flex w-full"> <div class=" flex w-full">
<div class=" mr-4"> <div class=" mr-4">
<img {#if message.role === 'user'}
src="{message.role == 'user' {#if $config === null}
? settings.gravatarUrl <img
? settings.gravatarUrl src="{settings.gravatarUrl ? settings.gravatarUrl : '/user'}.png"
: '/user' class=" max-w-[28px] object-cover rounded-full"
: '/favicon'}.png" alt="User profile"
class=" max-w-[28px] object-cover rounded-full" />
/> {:else}
<img
src={$user.profile_image_url}
class=" max-w-[28px] object-cover rounded-full"
alt="User profile"
/>
{/if}
{:else}
<img
src="/favicon.png"
class=" max-w-[28px] object-cover rounded-full"
alt="Ollama profile"
/>
{/if}
</div> </div>
<div class="w-full"> <div class="w-full">

View file

@ -11,7 +11,7 @@
let loaded = false; let loaded = false;
onMount(async () => { onMount(async () => {
const webBackendStatus = await fetch(`${WEBUI_API_BASE_URL}/`, { const resBackend = await fetch(`${WEBUI_API_BASE_URL}/`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -26,11 +26,11 @@
return null; return null;
}); });
console.log(webBackendStatus); console.log(resBackend);
await config.set(webBackendStatus); await config.set(resBackend);
if (webBackendStatus) { if ($config) {
if (webBackendStatus.auth) { if ($config.auth) {
if (localStorage.token) { if (localStorage.token) {
const res = await fetch(`${WEBUI_API_BASE_URL}/auths`, { const res = await fetch(`${WEBUI_API_BASE_URL}/auths`, {
method: 'GET', method: 'GET',
@ -49,9 +49,14 @@
return null; return null;
}); });
await user.set(res); if (res) {
await user.set(res);
} else {
localStorage.removeItem('token');
await goto('/auth');
}
} else { } else {
goto('/auth'); await goto('/auth');
} }
} }
} }

View file

@ -2,8 +2,10 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
import { config, user } from '$lib/stores'; import { config, user } from '$lib/stores';
import { onMount } from 'svelte';
import toast from 'svelte-french-toast'; import toast from 'svelte-french-toast';
let loaded = false;
let mode = 'signin'; let mode = 'signin';
let name = ''; let name = '';
@ -33,7 +35,7 @@
if (res) { if (res) {
console.log(res); console.log(res);
toast.success(`You're now logged in. Redirecting you to the main page."`); toast.success(`You're now logged in. Redirecting you to the main page.`);
localStorage.token = res.token; localStorage.token = res.token;
await user.set(res); await user.set(res);
goto('/'); goto('/');
@ -71,12 +73,15 @@
} }
}; };
if ($config === null || !$config.auth || ($config.auth && $user !== undefined)) { onMount(async () => {
goto('/'); if ($config === null || !$config.auth || ($config.auth && $user !== undefined)) {
} await goto('/');
}
loaded = true;
});
</script> </script>
{#if $config && $config.auth} {#if loaded && $config && $config.auth}
<div class="fixed m-10 z-50"> <div class="fixed m-10 z-50">
<div class="flex space-x-2"> <div class="flex space-x-2">
<div class=" self-center"> <div class=" self-center">
@ -1065,6 +1070,146 @@
</div> </div>
</div> </div>
<div class=" my-auto pb-36 w-full px-4">
<div class=" text-center flex flex-col justify-center">
<div class=" text-xl md:text-2xl font-bold">Get Started</div>
<div
class=" mt-4 flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-1 px-3 justify-center"
>
<button class=" flex-1 px-4 py-3.5 bg-blue-700 text-white font-medium rounded-lg">
Log in
</button>
<button class=" flex-1 px-4 py-3.5 bg-blue-700 text-white font-medium rounded-lg">
Sign up
</button>
</div>
</div>
</div>
</div> -->
<!-- <div class=" bg-white min-h-screen w-full flex flex-col">
<div class=" mt-6 mx-6">
<div class="flex space-x-2">
<div class=" self-center text-2xl font-semibold">Ollama</div>
<div class=" self-center">
<img src="/ollama.png" class=" w-5" />
</div>
</div>
</div>
<div class=" my-auto pb-36 w-full px-4">
<div class=" text-center flex flex-col justify-center">
<div class=" text-xl md:text-2xl font-bold">Get Started</div>
<div
class=" mt-4 flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-1 px-3 justify-center"
>
<button class=" flex-1 px-4 py-3.5 bg-blue-700 text-white font-medium rounded-lg">
Log in
</button>
<button class=" flex-1 px-4 py-3.5 bg-blue-700 text-white font-medium rounded-lg">
Sign up
</button>
</div>
</div>
</div>
</div> -->
<!-- <div class=" bg-white min-h-screen w-full flex flex-col">
<div class=" mt-6 mx-6">
<div class="flex space-x-2">
<div class=" self-center text-2xl font-semibold">Ollama</div>
<div class=" self-center">
<img src="/ollama.png" class=" w-5" />
</div>
</div>
</div>
<div class=" my-auto pb-36 w-full px-4">
<div class=" text-center flex flex-col justify-center">
<div class=" text-xl md:text-2xl font-bold">Get Started</div>
<div
class=" mt-4 flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-1 px-3 justify-center"
>
<button class=" flex-1 px-4 py-3.5 bg-blue-700 text-white font-medium rounded-lg">
Log in
</button>
<button class=" flex-1 px-4 py-3.5 bg-blue-700 text-white font-medium rounded-lg">
Sign up
</button>
</div>
</div>
</div>
</div> -->
<!-- <div class=" bg-white min-h-screen w-full flex flex-col">
<div class=" mt-6 mx-6">
<div class="flex space-x-2">
<div class=" self-center text-2xl font-semibold">Ollama</div>
<div class=" self-center">
<img src="/ollama.png" class=" w-5" />
</div>
</div>
</div>
<div class=" my-auto pb-36 w-full px-4">
<div class=" text-center flex flex-col justify-center">
<div class=" text-xl md:text-2xl font-bold">Get Started</div>
<div
class=" mt-4 flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-1 px-3 justify-center"
>
<button class=" flex-1 px-4 py-3.5 bg-blue-700 text-white font-medium rounded-lg">
Log in
</button>
<button class=" flex-1 px-4 py-3.5 bg-blue-700 text-white font-medium rounded-lg">
Sign up
</button>
</div>
</div>
</div>
</div> -->
<!-- <div class=" bg-white min-h-screen w-full flex flex-col">
<div class=" mt-6 mx-6">
<div class="flex space-x-2">
<div class=" self-center text-2xl font-semibold">Ollama</div>
<div class=" self-center">
<img src="/ollama.png" class=" w-5" />
</div>
</div>
</div>
<div class=" my-auto pb-36 w-full px-4">
<div class=" text-center flex flex-col justify-center">
<div class=" text-xl md:text-2xl font-bold">Get Started</div>
<div
class=" mt-4 flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-1 px-3 justify-center"
>
<button class=" flex-1 px-4 py-3.5 bg-blue-700 text-white font-medium rounded-lg">
Log in
</button>
<button class=" flex-1 px-4 py-3.5 bg-blue-700 text-white font-medium rounded-lg">
Sign up
</button>
</div>
</div>
</div>
</div> -->
<!-- <div class=" bg-white min-h-screen w-full flex flex-col">
<div class=" mt-6 mx-6">
<div class="flex space-x-2">
<div class=" self-center text-2xl font-semibold">Ollama</div>
<div class=" self-center">
<img src="/ollama.png" class=" w-5" />
</div>
</div>
</div>
<div class=" my-auto pb-36 w-full px-4"> <div class=" my-auto pb-36 w-full px-4">
<div class=" text-center flex flex-col justify-center"> <div class=" text-center flex flex-col justify-center">
<div class=" text-xl md:text-2xl font-bold">Get Started</div> <div class=" text-xl md:text-2xl font-bold">Get Started</div>