Merge pull request #2 from BlackChaosNL/fea/patch1

[PATCH 1] Add structure, first API and testing.
This commit was merged in pull request #2.
This commit is contained in:
Jeroen Vijgen
2025-03-25 19:10:29 +02:00
committed by GitHub
64 changed files with 1341 additions and 184 deletions
+2
View File
@@ -0,0 +1,2 @@
python 3.13.1
nodejs 23.4.0
+48
View File
@@ -0,0 +1,48 @@
{
admin off
email {$EMAIL_ADDRESS}
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
grace_period 30s
shutdown_delay 60s
}
(protect) {
@external {
not remote_ip private_ranges
}
abort @external 401
}
stoneedge.{$MAIN_DOMAIN} {
handle / {
reverse_proxy stoneedge:8000
}
}
stoneedge-staging.{$MAIN_DOMAIN} {
handle / {
import protect
abort
reverse_proxy stoneedge-staging:8000
}
}
{$MAIN_DOMAIN} {
encode zstd gzip
handle /ping {
@goingDown vars {http.shutting_down} true
respond @goingDown "Shutdown in {http.time_until_shutdown}" 503
respond "pong" 200
}
handle / {
abort 404
}
handle {
abort 404
}
}
+43 -36
View File
@@ -1,43 +1,19 @@
# OpenAssetManager # OpenAssetManager
Product for asset documentation for home to big business. Free base-system and non-free (paid) addons. Product for asset documentation for home to big business. Free base-system and non-free (paid) addons.
Our folder structure: ## Products
All of the projects are located in this project. This is a monorepo. Make sure you have ASDF installed.
### Web
All web projects are under `web`, they are react projects.
### API's
Our folder structure for the APIs:
``` ```
fastapi-project fastapi-project
├── alembic/ ├── migrations/
├── src
│ ├── auth
│ │ ├── router.py # auth main router with all the endpoints
│ │ ├── schemas.py # pydantic models
│ │ ├── models.py # database models
│ │ ├── dependencies.py # router dependencies
│ │ ├── config.py # local configs
│ │ ├── constants.py # module-specific constants
│ │ ├── exceptions.py # module-specific errors
│ │ ├── service.py # module-specific business logic
│ │ └── utils.py # any other non-business logic functions
│ ├── aws
│ │ ├── client.py # client model for external service communication
│ │ ├── schemas.py
│ │ ├── config.py
│ │ ├── constants.py
│ │ ├── exceptions.py
│ │ └── utils.py
│ └── posts
│ │ ├── router.py
│ │ ├── schemas.py
│ │ ├── models.py
│ │ ├── dependencies.py
│ │ ├── constants.py
│ │ ├── exceptions.py
│ │ ├── service.py
│ │ └── utils.py
│ ├── config.py # global configs
│ ├── models.py # global database models
│ ├── exceptions.py # global exceptions
│ ├── pagination.py # global module e.g. pagination
│ ├── database.py # db connection related stuff
│ └── main.py
├── tests/ ├── tests/
│ ├── auth │ ├── auth
│ ├── aws │ ├── aws
@@ -48,8 +24,39 @@ fastapi-project
│ ├── base.txt │ ├── base.txt
│ ├── dev.txt │ ├── dev.txt
│ └── prod.txt │ └── prod.txt
├── router.py # auth main router with all the endpoints
├── schemas.py # pydantic models
├── models.py # database models
│ ├── dependencies.py # router dependencies
│ ├── config.py # local configs
│ ├── constants.py # module-specific constants
│ ├── exceptions.py # module-specific errors
│ ├── service.py # module-specific business logic
│ └── utils.py # any other non-business logic functions
├── aws
│ ├── client.py # client model for external service communication
│ ├── schemas.py
│ ├── config.py
│ ├── constants.py
│ ├── exceptions.py
│ └── utils.py
└── posts
│ ├── router.py
│ ├── schemas.py
│ ├── models.py
│ ├── dependencies.py
│ ├── constants.py
│ ├── exceptions.py
│ ├── service.py
│ └── utils.py
├── config.py # global configs
├── models.py # global database models
├── exceptions.py # global exceptions
├── pagination.py # global module e.g. pagination
├── database.py # db connection related stuff
├── main.py
├── .env ├── .env
├── .gitignore ├── .gitignore
├── logging.ini ├── logging.ini
└── alembic.ini └── pyproject.toml
``` ```
+10 -8
View File
@@ -1,17 +1,19 @@
# Sets up the API before preventing root access to the alpine image
FROM python:3.13-alpine FROM python:3.13-alpine
WORKDIR /app WORKDIR /src
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY ./src/ /src/
RUN apk upgrade --no-cache &&\
uv pip install --system -r /src/requirements/requirements.txt
RUN adduser -D nonroot RUN adduser -D nonroot
USER nonroot USER nonroot
COPY ./src/ /app/
RUN pip install --upgrade pip &&\
pip install uv &&\
uv pip install --user --no-cache-dir --upgrade -r /code/requirements/requirements.txt
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["python", "-m"] ENTRYPOINT ["python", "-m"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["fastapi", "run"]
+24 -3
View File
@@ -1,6 +1,27 @@
class Settings: from fastapi.security import OAuth2PasswordBearer
PROJECT_NAME:str = "Open Asset Manager" from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore
from passlib.context import CryptContext # type: ignore
import pytz
class Settings(BaseSettings):
PROJECT_NAME: str = "StoneEdge Asset Management System"
PROJECT_VERSION: str = "0.0.1" PROJECT_VERSION: str = "0.0.1"
PROJECT_SUMMARY: str = "Product API for Open Asset Manager." PROJECT_SUMMARY: str = "Product API for StoneEdge."
PROJECT_PUBLIC_URL: str = "localhost"
SECRET_KEY: str | None = None
PSQL_USERNAME: str = "user"
PSQL_PASSWORD: str = "password"
PSQL_HOSTNAME: str = "localhost"
PSQL_PORT: int = 5432
PSQL_DB_NAME: str = "stoneedge"
PSQL_TEST_DB_NAME: str = "stoneedge_testing"
ACCESS_TOKEN_EXPIRE_MIN: int = 10
REFRESH_TOKEN_EXPIRE_MIN: int = 20
BACKEND_CORS_ORIGINS: list = ["*"]
CRYPT: CryptContext = CryptContext(schemes=["bcrypt"], deprecated="auto")
OAUTH2_SCHEME: OAuth2PasswordBearer = OAuth2PasswordBearer(tokenUrl="token")
model_config = SettingsConfigDict(env_file=".env")
settings = Settings() settings = Settings()
+28 -16
View File
@@ -1,16 +1,30 @@
from typing_extensions import Any from typing_extensions import Any
from tortoise import Tortoise from tortoise import Tortoise
import os from config import settings
from aerich import Command
db_url = os.getenv('PSQL_CONNECT_STR') modules: dict[str, Any] = {
modules: dict[str, Any] = {'models': [ "models": [
'.models', "modules.assets.models",
'.modules.auth.models', "modules.auth.models",
'.modules.assets.models', "modules.users.models",
]} "modules.organizations.models",
]
}
TORTOISE_ORM = { TORTOISE_ORM = {
"connections": {"default": db_url}, "connections": {
"default": {
"engine": "tortoise.backends.asyncpg",
"credentials": {
"host": settings.PSQL_HOSTNAME,
"database": settings.PSQL_DB_NAME,
"user": settings.PSQL_USERNAME,
"password": settings.PSQL_PASSWORD,
"port": settings.PSQL_PORT,
},
}
},
"apps": { "apps": {
"models": { "models": {
"models": modules.get("models", []) + ["aerich.models"], "models": modules.get("models", []) + ["aerich.models"],
@@ -19,14 +33,12 @@ TORTOISE_ORM = {
}, },
} }
async def init_db():
await Tortoise.init(
db_url=db_url,
modules=modules
)
async def migrate_db(): async def migrate_db():
await init_db() aerich = Command(tortoise_config=TORTOISE_ORM)
await aerich.init()
await aerich.upgrade(run_in_transaction=True)
await Tortoise.init(config=TORTOISE_ORM)
# Generate the schema async def end_connections_to_db():
await Tortoise.generate_schemas(safe=True) await Tortoise.close_connections()
+38 -19
View File
@@ -1,30 +1,49 @@
from fastapi import FastAPI from fastapi import FastAPI
from starlette.responses import RedirectResponse from config import settings
from .config import settings from database import end_connections_to_db, migrate_db
from .modules.assets.router import router as asset_router from responses import msgspec_jsonresponse
from dotenv import load_dotenv from contextlib import asynccontextmanager
from tortoise.contrib.fastapi import register_tortoise from fastapi.middleware.cors import CORSMiddleware
from .database import db_url, modules
from router import router as root_router
from modules.assets.router import router as asset_router
from modules.auth.router import router as auth_router
from modules.users.router import router as users_router
from modules.organizations.router import router as organizations_router
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
@asynccontextmanager
async def lifespan(_: FastAPI):
await migrate_db()
yield
await end_connections_to_db()
load_dotenv()
app = FastAPI( app = FastAPI(
lifespan=lifespan,
title=settings.PROJECT_NAME, title=settings.PROJECT_NAME,
version=settings.PROJECT_VERSION, version=settings.PROJECT_VERSION,
summary=settings.PROJECT_SUMMARY, summary=settings.PROJECT_SUMMARY,
default_response_class=msgspec_jsonresponse,
) )
register_tortoise( app.add_middleware(HTTPSRedirectMiddleware)
app, app.add_middleware(TrustedHostMiddleware, allowed_hosts=[settings.PROJECT_PUBLIC_URL,])
db_url=db_url,
modules=modules,
generate_schemas=True,
add_exception_handlers=True,
)
# Set all CORS enabled origins
if settings.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(root_router)
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(organizations_router)
app.include_router(asset_router) app.include_router(asset_router)
@app.get("/")
async def main():
return RedirectResponse(url="/docs")
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
from ptpython.repl import embed # type: ignore
from database import *
import asyncio
async def setup():
try:
await embed(globals=globals(), return_asyncio_coroutine=True, patch_stdout=True)
except EOFError:
loop.stop()
if __name__ == "__main__":
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
asyncio.ensure_future(setup())
loop.run_forever()
except KeyboardInterrupt:
pass
@@ -0,0 +1,75 @@
from tortoise import BaseDBAsyncClient
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "asset" (
"id" UUID NOT NULL PRIMARY KEY,
"name" VARCHAR(128) NOT NULL
);
CREATE TABLE IF NOT EXISTS "acl" (
"id" UUID NOT NULL PRIMARY KEY,
"READ" BOOL NOT NULL DEFAULT False,
"WRITE" BOOL NOT NULL DEFAULT False,
"REPORT" BOOL NOT NULL DEFAULT False,
"MANAGE" BOOL NOT NULL DEFAULT False,
"ADMIN" BOOL NOT NULL DEFAULT False
);
COMMENT ON TABLE "acl" IS 'ACL';
CREATE TABLE IF NOT EXISTS "organization" (
"created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"name" VARCHAR(128) NOT NULL,
"type" VARCHAR(128) NOT NULL,
"disabled" BOOL NOT NULL DEFAULT False
);
COMMENT ON TABLE "organization" IS 'Organization';
CREATE TABLE IF NOT EXISTS "user" (
"created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"email" VARCHAR(128) NOT NULL,
"username" TEXT NOT NULL,
"name" TEXT NOT NULL,
"surname" TEXT NOT NULL,
"password" VARCHAR(128),
"disabled" BOOL NOT NULL DEFAULT False
);
COMMENT ON TABLE "user" IS 'User';
CREATE TABLE IF NOT EXISTS "token" (
"created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"token_type" VARCHAR(128) NOT NULL DEFAULT 'Bearer',
"access_token" VARCHAR(128),
"refresh_token" VARCHAR(128),
"disabled" BOOL NOT NULL DEFAULT False,
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
COMMENT ON TABLE "token" IS 'Token';
CREATE TABLE IF NOT EXISTS "membership" (
"created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
"modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"disabled" BOOL NOT NULL DEFAULT False,
"acl_id" UUID NOT NULL REFERENCES "acl" ("id") ON DELETE CASCADE,
"organization_id" UUID NOT NULL REFERENCES "organization" ("id") ON DELETE CASCADE,
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
COMMENT ON TABLE "membership" IS 'Membership';
CREATE TABLE IF NOT EXISTS "aerich" (
"id" SERIAL NOT NULL PRIMARY KEY,
"version" VARCHAR(255) NOT NULL,
"app" VARCHAR(100) NOT NULL,
"content" JSONB NOT NULL
);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
"""
@@ -0,0 +1,15 @@
from tortoise import BaseDBAsyncClient
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "asset" ADD "disabled_at" TIMESTAMPTZ;
ALTER TABLE "asset" ADD "modified_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "asset" ADD "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "asset" DROP COLUMN "disabled_at";
ALTER TABLE "asset" DROP COLUMN "modified_at";
ALTER TABLE "asset" DROP COLUMN "created_at";"""
@@ -0,0 +1,13 @@
from tortoise import BaseDBAsyncClient
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "token" ALTER COLUMN "refresh_token" TYPE TEXT USING "refresh_token"::TEXT;
ALTER TABLE "token" ALTER COLUMN "access_token" TYPE TEXT USING "access_token"::TEXT;"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "token" ALTER COLUMN "refresh_token" TYPE VARCHAR(128) USING "refresh_token"::VARCHAR(128);
ALTER TABLE "token" ALTER COLUMN "access_token" TYPE VARCHAR(128) USING "access_token"::VARCHAR(128);"""
+11
View File
@@ -0,0 +1,11 @@
from tortoise import fields
class CMDMixin():
"""
Created, modified and delete mixin, these are required for every class.
"""
created_at = fields.DatetimeField(null=True, auto_now_add=True)
modified_at = fields.DatetimeField(null=True, auto_now=True)
disabled_at = fields.DatetimeField(null=True)
-74
View File
@@ -1,74 +0,0 @@
from enum import Enum
from typing import Type
from tortoise.exceptions import ConfigurationError
from tortoise.models import Model
from tortoise import fields
class EnumField(fields.CharField):
"""
An example extension to CharField that serializes Enums
to and from a str representation in the DB.
"""
def __init__(self, enum_type: Type[Enum], **kwargs):
super().__init__(128, **kwargs)
if not issubclass(enum_type, Enum):
raise ConfigurationError("{} is not a subclass of Enum!".format(enum_type))
self._enum_type = enum_type
def to_db_value(self, value: Enum, instance) -> str:
return value.value
def to_python_value(self, value: str) -> Enum:
try:
return self._enum_type(value)
except Exception:
raise ValueError(
"Database value {} does not exist on Enum {}.".format(value, self._enum_type)
)
class OrganizationType(Enum):
HOME = 1 # Home use (Any size)
SMALL_ORGANIZATION = 2 # 1-100
MEDIUM_ORGANIZATION = 3 # 100 - 500
LARGE_ORGANIZATION = 4 # 500 - 1000
EXTRA_LARGE_ORGANIZATION = 5 # 1000 - 5000+
class CreatedAndModifiedMixin():
created = fields.DatetimeField(null=True, auto_now_add=True)
modified = fields.DatetimeField(null=True, auto_now=True)
class Organization(Model, CreatedAndModifiedMixin):
id = fields.UUIDField(primary_key=True)
name = fields.CharField(max_length=128)
type = EnumField(OrganizationType)
users = fields.ManyToManyField('models.User',
related_name='members',
through="Membership",
forward_key='user_id',
backward_key='organization_id',
null=True
)
def __str__(self) -> str:
return f"{self.id} - {self.name}"
class User(Model, CreatedAndModifiedMixin):
id = fields.UUIDField(primary_key=True)
name = fields.TextField()
surname = fields.TextField()
password = fields.CharField(max_length=128, null=True)
organizations = fields.ManyToManyField('models.Organization',
related_name='members',
through='Membership',
forward_key='organization_id',
backward_key='user_id',
null=True
)
def __str__(self) -> str:
return f"{self.id} - {self.name} {self.surname}"
class Membership(Model, CreatedAndModifiedMixin):
organization = fields.ForeignKeyField('models.Organization')
user = fields.ForeignKeyField('models.User')
@@ -1,6 +1,7 @@
from tortoise.models import Model from tortoise.models import Model
from tortoise import fields from tortoise import fields
from mixins.CMDMixin import CMDMixin
class Asset(Model): class Asset(Model, CMDMixin):
id = fields.UUIDField(primary_key=True) id = fields.UUIDField(primary_key=True)
name = fields.CharField(max_length=128) name = fields.CharField(max_length=128)
@@ -2,25 +2,22 @@ from uuid import UUID
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from .models import Asset
router = APIRouter( router = APIRouter(
prefix="/assets" prefix="/assets"
) )
@router.get("/") @router.get("/")
async def get_all_assets(): async def get_all_assets():
return await Asset.get_or_none() pass
@router.post("/") @router.post("/")
async def create_asset(name: str): async def create_asset(name: str):
asset = await Asset.create(name=name) pass
return asset
@router.delete("/", status_code=204) @router.delete("/", status_code=204)
async def delete_asset(remove_id: UUID): async def delete_asset(remove_id: UUID):
await Asset.filter(id=remove_id).delete() pass
@router.get("/{asset_id}") @router.get("/{asset_id}")
async def get_asset(asset_id: UUID): async def get_asset(asset_id: UUID):
return Asset.filter(id=asset_id).get_or_none() pass
@@ -1 +1,29 @@
import pytz
from tortoise.models import Model
from tortoise import fields
import uuid
from datetime import datetime
from mixins.CMDMixin import CMDMixin
from config import settings
class Token(Model, CMDMixin):
"""
Token
Creates the access tokens for the User
"""
id: uuid.UUID = fields.UUIDField(primary_key=True)
user: uuid.UUID = fields.ForeignKeyField("models.User")
token_type: str = fields.CharField(max_length=128, default="Bearer")
access_token: str = fields.TextField(null=True)
refresh_token: str = fields.TextField(null=True)
disabled: bool = fields.BooleanField(default=False)
async def delete(self) -> None:
self.disabled = True
self.disabled_at = datetime.now(tz=pytz.UTC)
await self.save()
@@ -1 +1,141 @@
from datetime import datetime
from typing import Annotated
import uuid
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.routing import APIRouter
import pytz
from modules.users.utils import get_current_active_user
from modules.auth.utils import create_jwt_tokens, get_tokens_from_logged_in_user
from modules.auth.models import Token
from modules.users.models import User
from fastapi import Depends, HTTPException, status
from tortoise.expressions import Q
from config import settings
from modules.users.schemas import user_model
from modules.auth.schemas import register_model
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
account_error: str = "E-Mail Address or password is incorrect"
token_error: str = "Refresh token not found or something went wrong."
user_exists: str = "Account failed to create, please contact support."
password_failed: str = "Password validation failed, please try again."
crypt = settings.CRYPT
@router.post("/login")
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
"""
Login
Logs the user into our API, creates tokens and passes them back to User.
"""
user: User | None = await User.filter(email=form.username).first()
if user is None:
raise HTTPException(status_code=401, detail=account_error)
if user.check_against_password(form.password) is False:
raise HTTPException(status_code=401, detail=account_error)
if user.disabled is True:
raise HTTPException(status_code=401, detail=account_error)
tokens = await create_jwt_tokens(user)
return {"jwt": tokens}
@router.get("/logout", status_code=204)
async def logout(user: Annotated[User, Depends(get_current_active_user)]):
"""
Logout
Logout destroys all tokens for User that are currently active.
"""
get_all_tokens = await Token.filter(Q(user__id=user.id))
if get_all_tokens is None:
raise HTTPException(
status_code=status.HTTP_204_NO_CONTENT, detail="An error occurred."
)
for token in get_all_tokens:
await token.delete()
return
@router.post("/refresh")
async def refresh_login(
refresh_token: Annotated[Token | None, Depends(get_tokens_from_logged_in_user)],
):
"""
Refresh
After ging this route a token that is active and not disabled, we disable ALL other tokens and pass along new tokens.
Tokens are alive for about 10 minutes. Refresh tokens are alive for 20 minutes.
"""
if refresh_token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=token_error,
)
# Disable tokens if used after expiration.
if (
refresh_token.created_at >= datetime.now(tz=pytz.utc)
and refresh_token.disabled is False
):
refresh_token.delete()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=token_error,
)
if refresh_token.disabled is True:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=token_error,
)
get_all_tokens = await Token.filter(Q(user__id=refresh_token.user_id))
for token in get_all_tokens:
if token.id != refresh_token.id:
await token.delete()
tokens = await create_jwt_tokens(
user=await User.filter(Q(id=refresh_token.user_id)).first()
)
return {"jwt": tokens}
@router.post("/register", status_code=201, response_model=user_model)
async def register(user: register_model):
# Prevent existing users from reapplying for our system.
existing_user: User | None = await User.filter(
Q(email=user.email)
& Q(username=user.username)
& Q(name=user.name)
& Q(surname=user.surname)
).get_or_none()
if existing_user is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=user_exists,
)
if user.password != user.validate_password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=password_failed,
)
return await User.create(
email=user.email,
username=user.username,
name=user.name,
surname=user.surname,
password=crypt.hash(user.password),
)
@@ -0,0 +1,14 @@
from pydantic import BaseModel, EmailStr
from tortoise.contrib.pydantic import pydantic_model_creator
from modules.auth.models import Token
token_model = pydantic_model_creator(Token)
class register_model(BaseModel):
email: EmailStr
username: str
name: str
surname: str
password: str
validate_password: str
@@ -0,0 +1,82 @@
from datetime import timedelta
from typing import Annotated
import uuid, time
from tortoise.expressions import Q
from fastapi import Depends, HTTPException, status
from modules.users.models import User
from modules.auth.models import Token
from config import settings
from joserfc import jwt # type: ignore
from joserfc.jwk import OctKey # type: ignore
from config import settings
crypt = settings.CRYPT
def create_token(user_id: uuid, offset: timedelta) -> str:
"""
Creates a JWT token
"""
user = str(user_id)
curr_time = int(time.time())
return jwt.encode(
{"alg": "HS256", "typ": "JWT"},
{
"iss": f"{settings.PROJECT_PUBLIC_URL}",
"sub": f"id:{user}",
"nbf": curr_time,
"iat": curr_time,
"exp": int(time.time() + offset.total_seconds()),
},
OctKey.import_key(settings.SECRET_KEY),
)
async def create_jwt_tokens(user: User) -> Token:
"""
Create a Token class with the following entities:
1) A user that is attached to the Token
2) A fresh Auth Token
3) A fresh Refresh Token.
This is then returned in the form of an Token class.
"""
auth_token = create_token(
user_id=user.id, offset=timedelta(settings.ACCESS_TOKEN_EXPIRE_MIN)
)
refresh_token = create_token(
user_id=user.id, offset=timedelta(settings.REFRESH_TOKEN_EXPIRE_MIN)
)
return await Token.create(
user=user,
access_token=auth_token,
refresh_token=refresh_token,
)
async def get_tokens_from_logged_in_user(
token: Annotated[str, Depends(settings.OAUTH2_SCHEME)]
) -> User | None:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="An issue occurred with the token.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload: jwt.Token = jwt.decode(
token, OctKey.import_key(settings.SECRET_KEY), algorithms=["HS256"]
)
id: str | None = payload.claims.get("sub", None)
if id is None:
raise credentials_exception
user_id = id.split(":")[1]
except:
raise credentials_exception
return await Token.filter(Q(refresh_token=token) & Q(user__id=user_id)).first()
@@ -0,0 +1,89 @@
from datetime import datetime
from enum import Enum
from typing import Type
import uuid
import pytz
from tortoise.exceptions import ConfigurationError
from tortoise.models import Model
from tortoise import fields
from mixins.CMDMixin import CMDMixin
class EnumField(fields.CharField):
"""
Serializes Enums to and from a str representation in the DB.
"""
def __init__(self, enum_type: Type[Enum], **kwargs):
super().__init__(128, **kwargs)
if not issubclass(enum_type, Enum):
raise ConfigurationError("{} is not a subclass of Enum!".format(enum_type))
self._enum_type = enum_type
def to_db_value(self, value: Enum, _) -> str:
return value.value
def to_python_value(self, value: str) -> Enum:
try:
return self._enum_type(value)
except Exception:
raise ValueError(
"Database value {} does not exist on Enum {}.".format(
value, self._enum_type
)
)
class OrganizationType(Enum):
"""
Represents the following:
1. Is this a commercial entity or not?
2. What size is it?
All choices should be representative of the org.
"""
HOME: str = "home" # Home use (Any size)
NON_PROFIT: str = "non_profit"
SMALL_ORGANIZATION: str = "s_org" # 1-100
MEDIUM_ORGANIZATION: str = "m_org" # 100 - 500
LARGE_ORGANIZATION: str = "l_org" # 500 - 1000
EXTRA_LARGE_ORGANIZATION: str = "xl_org" # 1000 - 5000+
class Organization(Model, CMDMixin):
"""
Organization
This class holds the organization for a household / organization
and makes sure that we can add users.
"""
id: uuid.UUID = fields.UUIDField(primary_key=True)
name: str = fields.CharField(max_length=128)
type: str = EnumField(OrganizationType)
users: uuid.UUID = fields.ManyToManyField(
"models.User",
related_name="members",
through="Membership",
forward_key="user_id",
backward_key="organization_id",
null=True,
on_delete=fields.NO_ACTION,
)
disabled: bool = fields.BooleanField(default=False)
def __str__(self) -> str:
return f"{self.id} - {self.name}"
async def delete(self, force: bool = False) -> None:
if force:
await Model.delete(self)
else:
self.disabled = True
self.disabled_at = datetime.now(tz=pytz.UTC)
await self.save()
@@ -0,0 +1,17 @@
from fastapi import APIRouter
router = APIRouter(prefix="/api/v1/organizations")
@router.get("/")
def all_organizations():
pass
@router.delete("/")
def delete_organization():
pass
@router.post("/create")
def create_organization():
pass
@@ -0,0 +1,6 @@
from tortoise.contrib.pydantic import pydantic_model_creator
from modules.organizations.models import Organization
OrganizationModel = pydantic_model_creator(Organization)
@@ -0,0 +1,112 @@
from datetime import datetime
import uuid
from pydantic import EmailStr
import pytz
from tortoise.models import Model
from tortoise import fields
from modules.organizations.models import Organization
from mixins.CMDMixin import CMDMixin
from config import settings
crypt = settings.CRYPT
class User(Model, CMDMixin):
"""
User
This holds all of our users
"""
id: uuid.UUID = fields.UUIDField(primary_key=True)
email: EmailStr = fields.CharField(max_length=128)
username: str = fields.TextField(max_length=128)
name: str = fields.TextField(max_length=128)
surname: str = fields.TextField(max_length=128)
password: str = fields.CharField(max_length=128, null=True)
organizations: uuid = fields.ManyToManyField(
"models.Organization",
related_name="members",
through="Membership",
forward_key="organization_id",
backward_key="user_id",
null=True,
on_delete=fields.NO_ACTION,
)
disabled: bool = fields.BooleanField(default=False)
# tokens = fields.ForeignKeyField("models.Token")
def __str__(self) -> str:
return f"{self.id} - {self.name} {self.surname}"
async def set_password(self, password: str) -> None:
self.password = crypt.hash(password)
await self.save() # Make sure to save the model in DB
def check_against_password(self, password: str) -> bool:
return crypt.verify(password, self.password)
async def update_password(
self, old_password, new_password: str, verify_new_password: str
) -> bool:
if self.check_against_password(old_password) is False:
return False
if new_password is not verify_new_password:
return False
await self.set_password(new_password)
async def delete(self, force: bool = False) -> None:
if force:
await Model.delete(self)
else:
self.disabled = True
self.disabled_at = datetime.now(tz=pytz.UTC)
await self.save()
class ACL(Model):
"""
ACL
Access control lists, every invited user gets an ACL and this decides whether you grant / deny access to certain parts of our system.
"""
id: uuid.UUID = fields.UUIDField(primary_key=True)
READ: bool = fields.BooleanField(default=False)
WRITE: bool = fields.BooleanField(default=False)
REPORT: bool = fields.BooleanField(default=False)
MANAGE: bool = fields.BooleanField(default=False)
ADMIN: bool = fields.BooleanField(default=False)
def __str__(self) -> str:
return f"""
ID: {self.id},
READ: {self.READ},
WRITE: {self.WRITE},
REPORT: {self.REPORT},
MANAGE: {self.MANAGE},
ADMIN: {self.ADMIN}
"""
class Membership(Model, CMDMixin):
"""
Membership
Creates a connection between an user and a company together with an ACL.
"""
id: uuid.UUID = fields.UUIDField(primary_key=True)
organization: Organization = fields.ForeignKeyField("models.Organization")
user: User = fields.ForeignKeyField("models.User")
acl: ACL = fields.ForeignKeyField("models.ACL")
disabled: bool = fields.BooleanField(default=False)
async def delete(self, force: bool = False) -> None:
if force:
await Model.delete(self)
else:
self.disabled = True
self.disabled_at = datetime.now(tz=pytz.UTC)
await self.save()
@@ -0,0 +1,20 @@
from fastapi import APIRouter
from modules.users.models import User
router = APIRouter(prefix="/api/v1/users", tags=["users"])
@router.get("/")
def get_all_users():
pass
@router.post("/")
def create_user():
pass
@router.get("/me")
def get_user():
pass
@@ -0,0 +1,5 @@
from tortoise.contrib.pydantic import pydantic_model_creator
from modules.users.models import User
user_model = pydantic_model_creator(User, exclude=["password"])
@@ -0,0 +1,47 @@
from typing import Annotated
from joserfc import jwt # type: ignore
from joserfc.jwk import OctKey # type: ignore
from tortoise.expressions import Q
from fastapi import Depends, HTTPException, status
from modules.users.models import User
from config import settings
async def get_user_from_token(
token: Annotated[str, Depends(settings.OAUTH2_SCHEME)]
) -> User | None:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="An issue occurred with the token.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload: jwt.Token = jwt.decode(
token, OctKey.import_key(settings.SECRET_KEY), algorithms=["HS256"]
)
id: str | None = payload.claims.get("sub", None)
if id is None:
raise credentials_exception
user_id = id.split(":")[1]
except:
raise credentials_exception
return await User.filter(Q(id=user_id)).first()
async def get_current_active_user(
user: Annotated[User | None, Depends(get_user_from_token)],
) -> User:
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is not found or active",
)
if user.disabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is not found or active",
)
return user
+14
View File
@@ -1,4 +1,18 @@
[tool.black]
exclude = '''/
# Default values for Black.
\.eggs|\.git|\.hg|\.mypy_cache|\.nox|\.tox|\.venv|\.svn|_build|buck-out|build|dist|
/'''
line-length = 88
[tool.aerich] [tool.aerich]
tortoise_orm = "database.TORTOISE_ORM" tortoise_orm = "database.TORTOISE_ORM"
location = "./migrations" location = "./migrations"
src_folder = "./." src_folder = "./."
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
testpaths = [
"tests/",
]
@@ -1,7 +1,21 @@
aerich==0.8.0 aerich>=0.8.0
fastapi[standard]==0.115.5 fastapi[all]>=0.115.5
uvicorn==0.31.1 python-dotenv>=0.21.0
tortoise-orm[asyncpg]==0.22.1 tortoise-orm[asyncpg]>=0.22.1
black==24.10.0 uvicorn>=0.31.1
python-dotenv==1.0.1 black>=24.10.0
uvicorn[standard]==0.34.0 joserfc>=1.0.1
passlib>=1.7.4
pytz>=2024.2
ptpython>=0.25
msgspec>=0.19.0
bcrypt>=4.2.1
# Test Suite
httpx>=0.28.1
pytest>=8.3.4
mock>=5.1.0
asyncio>=3.4.3
pytest-mock>=3.14.0
pytest-asyncio>=0.25.3
asgi-lifespan>=2.1.0
+16
View File
@@ -0,0 +1,16 @@
from typing import Any
from fastapi.responses import JSONResponse
try:
import msgspec # type: ignore
except ImportError: # pragma: nocover
msgspec = None # type: ignore
class msgspec_jsonresponse(JSONResponse):
"""
JSON Response using the high-performance msgspec lib to serialize data to JSON.
"""
def render(self, content: Any) -> bytes:
assert msgspec is not None, "msgspec must be installed to use msgspec_jsonresponse"
return msgspec.json.encode(content)
+15
View File
@@ -0,0 +1,15 @@
from fastapi import APIRouter
from fastapi.responses import JSONResponse, RedirectResponse
router = APIRouter(prefix="/api/v1")
@router.get("/")
async def main() -> RedirectResponse:
return RedirectResponse(url="/docs")
@router.get("/ping")
async def ping() -> JSONResponse:
return {"ping": "pong!"}
+54
View File
@@ -0,0 +1,54 @@
import asyncio
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import httpx, pytest
from config import settings
from glob import glob
from asgi_lifespan import LifespanManager # type: ignore
settings.PSQL_DB_NAME = settings.PSQL_TEST_DB_NAME
pytest_plugins = [
fixture.replace("/", ".").replace("\\", ".").replace(".py", "")
for fixture in glob("tests/fixtures/*.py")
if "__" not in fixture
]
try:
from main import app
except ImportError:
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).parent.parent))
from main import app
ClientManagerType = AsyncGenerator[httpx.AsyncClient, None]
@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio"
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop()
yield loop
loop.close()
@asynccontextmanager
async def client_manager(app, base_url="https://localhost", **kw) -> ClientManagerType:
app.state.testing = True
async with LifespanManager(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url=base_url, **kw) as c:
yield c
@pytest.fixture(scope="module")
async def client() -> ClientManagerType:
async with client_manager(app) as c:
yield c
View File
@@ -0,0 +1,86 @@
from modules.organizations.models import Organization, OrganizationType
from modules.users.models import ACL, Membership, User
import pytest # type=ignore
from config import settings
crypt = settings.CRYPT
@pytest.fixture()
async def use_user_account():
org, _ = await Organization.get_or_create(
id="6ad4c94e-0522-4912-8d16-02d451f4c92d",
defaults={
"name": "User's Organization",
"type": OrganizationType.HOME,
},
)
acl, _ = await ACL.get_or_create(
id="a4e927a3-36e5-4761-badb-0a44ade6616f",
defaults={
"READ": True,
"WRITE": True,
"REPORT": True,
"MANAGE": False,
"ADMIN": False,
},
)
user, _ = await User.get_or_create(
id="24235427-9662-4ba3-a9c5-00000000000b",
defaults={
"email": "user@localhost.com",
"username": "user",
"name": "awesome",
"surname": "user",
"password": crypt.hash("userpassword"),
},
)
membership, _ = await Membership.get_or_create(
id="833b9511-b2da-4760-8fa4-1a5c7059911e",
defaults={
"organization": org,
"user": user,
"acl": acl,
},
)
return org, acl, user, membership
@pytest.fixture()
async def use_admin_account():
org, _ = await Organization.get_or_create(
id="de001f44-1bb8-4667-9f9d-2d62d6ad7270",
defaults={
"name": "Admin's Organization",
"type": OrganizationType.EXTRA_LARGE_ORGANIZATION,
},
)
acl, _ = await ACL.get_or_create(
id="83c1bfe6-c2ed-4ba1-be03-0e5c1960ec31",
defaults={
"READ": True,
"WRITE": True,
"REPORT": True,
"MANAGE": True,
"ADMIN": True,
},
)
user, _ = await User.get_or_create(
id="24235427-9662-4ba3-a9c5-00000000000a",
defaults={
"email": "admin@localhost.com",
"username": "admin",
"name": "awesome",
"surname": "admin",
"password": crypt.hash("adminpassword"),
},
)
membership, _ = await Membership.get_or_create(
id="393473ee-c218-4bcf-82cd-cb676c4d8a33",
defaults={
"organization": org,
"user": user,
"acl": acl,
},
)
return org, acl, user, membership
@@ -0,0 +1,200 @@
from modules.users.models import User
import pytest # type: ignore
from httpx import AsyncClient
from config import settings
from unittest.mock import ANY
from tortoise.expressions import Q
crypt = settings.CRYPT
class TestAuthentication(object):
@pytest.mark.asyncio
async def test_authentication_with_non_existing_user_and_password(
self, client: AsyncClient
):
response = await client.post(
"https://localhost/api/v1/auth/login",
data={
"username": "non-existing@localhost.com",
"password": "password",
"grant_type": "password",
},
)
assert response.status_code == 401
assert response.json() == {"detail": "E-Mail Address or password is incorrect"}
@pytest.mark.asyncio
async def test_authentication_with_existing_user_and_wrong_password(
self, client: AsyncClient, use_admin_account
):
_, _, _, _ = use_admin_account
response = await client.post(
"https://localhost/api/v1/auth/login",
data={
"username": "admin@localhost.com",
"password": "password",
"grant_type": "password",
},
)
assert response.status_code == 401
assert response.json() == {"detail": "E-Mail Address or password is incorrect"}
@pytest.mark.asyncio
async def test_authentication_with_existing_user_and_password(
self, client: AsyncClient, use_admin_account
):
_, _, admin, _ = use_admin_account
response = await client.post(
"https://localhost/api/v1/auth/login",
data={
"username": "admin@localhost.com",
"password": "adminpassword",
"grant_type": "password",
},
)
assert response.status_code == 200
assert response.json() == {
"jwt": {
"created_at": ANY,
"user_id": str(admin.id),
"id": ANY,
"modified_at": ANY,
"disabled_at": None,
"refresh_token": ANY,
"disabled": False,
"access_token": ANY,
"token_type": "Bearer",
}
}
@pytest.mark.asyncio
async def test_logging_out_destroys_tokens(
self, client: AsyncClient, use_user_account
):
_, _, user, _ = use_user_account
response = await client.post(
"https://localhost/api/v1/auth/login",
data={
"username": "user@localhost.com",
"password": "userpassword",
"grant_type": "password",
},
)
assert response.status_code == 200
assert response.json() == {
"jwt": {
"created_at": ANY,
"user_id": str(user.id),
"id": ANY,
"modified_at": ANY,
"disabled_at": None,
"refresh_token": ANY,
"disabled": False,
"access_token": ANY,
"token_type": "Bearer",
}
}
access_token = response.json()["jwt"]["access_token"]
refresh_token = response.json()["jwt"]["refresh_token"]
logout = await client.get(
"https://localhost/api/v1/auth/logout",
headers={"Authorization": f"Bearer {access_token}"},
)
assert logout.status_code == 204
refresh_request = await client.post(
"https://localhost/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {refresh_token}"},
)
assert refresh_request.status_code == 401
assert refresh_request.json() == {
"detail": "Refresh token not found or something went wrong."
}
@pytest.mark.asyncio
async def test_create_new_tokens_upon_refresh(
self, client: AsyncClient, use_admin_account
):
_, _, admin, _ = use_admin_account
token = await client.post(
"https://localhost/api/v1/auth/login",
data={
"username": "admin@localhost.com",
"password": "adminpassword",
"grant_type": "password",
},
)
assert token.status_code == 200
assert token.json() == {
"jwt": {
"created_at": ANY,
"user_id": str(admin.id),
"id": ANY,
"modified_at": ANY,
"disabled_at": None,
"refresh_token": ANY,
"disabled": False,
"access_token": ANY,
"token_type": "Bearer",
}
}
refresh_token = token.json()["jwt"]["refresh_token"]
response2 = await client.post(
"https://localhost/api/v1/auth/refresh",
headers={"Authorization": f"Bearer {refresh_token}"},
)
assert response2.status_code == 200
assert response2.json() == {
"jwt": {
"created_at": ANY,
"user_id": str(admin.id),
"id": ANY,
"modified_at": ANY,
"disabled_at": None,
"refresh_token": ANY,
"disabled": False,
"access_token": ANY,
"token_type": "Bearer",
}
}
@pytest.mark.asyncio
async def test_setup_new_account(self, client: AsyncClient):
# Ensure account is never available. Prevents account already being available.
check_if_account_exists: User | None = await User.filter(
Q(email="superuser@localhost.com")
).get_or_none()
if check_if_account_exists:
await check_if_account_exists.delete(force=True)
account = await client.post(
"https://localhost/api/v1/auth/register",
json={
"email": "superuser@localhost.com",
"username": "superuser",
"name": "awesome",
"surname": "superuser",
"password": "superuserpassword",
"validate_password": "superuserpassword",
},
)
assert account.status_code == 201
assert account.json() == {
"created_at": ANY,
"disabled": False,
"disabled_at": None,
"email": "superuser@localhost.com",
"id": ANY,
"modified_at": ANY,
"name": "awesome",
"surname": "superuser",
"username": "superuser",
}
@@ -0,0 +1,13 @@
import pytest # type: ignore
from httpx import AsyncClient
class TestRootRoute(object):
async def test_read_docs_on_main_route(self, client: AsyncClient):
response = await client.get("https://localhost/api/v1/")
assert response.status_code == 307
async def test_get_pong(self, client: AsyncClient):
response = await client.get("https://localhost/api/v1/ping")
assert response.status_code == 200
assert response.json() == {"ping": "pong!"}
+25 -13
View File
@@ -1,8 +1,12 @@
version: "3.7" volumes:
caddy_data:
caddy_config:
postgres:
services: services:
caddy: caddy:
image: caddy:<version> container_name: caddy
image: docker.io/caddy/caddy:2.9-alpine
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
@@ -12,9 +16,15 @@ services:
- ./Caddyfile:/etc/caddy/Caddyfile - ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/ping"]
interval: 5m
timeout: 5s
retries: 3
start_period: 15s
fastapi: stoneedge:
container_name: "fastapi" container_name: "stoneedge"
restart: unless-stopped restart: unless-stopped
build: build:
context: ./api/asset_manager context: ./api/asset_manager
@@ -22,19 +32,21 @@ services:
ports: ports:
- 8000:8000 - 8000:8000
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"] test: ["CMD", "curl", "-f", "http://localhost:8000/ping"]
interval: 5m interval: 5m
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 15s start_period: 15s
links:
- "postgres"
postgres: postgres:
container_name: "postgres" container_name: postgres_container
image: postgres image: docker.io/postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-passwd}
PGDATA: /data/postgres
volumes:
- postgres:/data/postgres
ports:
- "5432:5432"
restart: unless-stopped restart: unless-stopped
volumes:
caddy_data:
caddy_config:

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File