Compare commits

..

No commits in common. "permission_exceptions" and "master" have entirely different histories.

22 changed files with 870 additions and 1645 deletions

View file

@ -5,18 +5,11 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Main App", "name": "Python: Modul",
"type": "python", "type": "python",
"request": "launch", "request": "launch",
"module": "kiwi_vpn_api.main", "module": "kiwi_vpn_api.main",
"justMyCode": true "justMyCode": true
},
{
"name": "EasyRSA script",
"type": "python",
"request": "launch",
"module": "kiwi_vpn_api.easyrsa",
"justMyCode": true
} }
] ]
} }

View file

@ -11,6 +11,5 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports": true "source.organizeImports": true
}, }
"git.closeDiffOnOperation": true
} }

View file

@ -9,17 +9,19 @@ Pydantic models might have convenience methods attached.
from __future__ import annotations from __future__ import annotations
import functools
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from secrets import token_urlsafe from secrets import token_urlsafe
from typing import Any
from jose import JWTError, jwt from jose import JWTError, jwt
from jose.constants import ALGORITHMS from jose.constants import ALGORITHMS
from passlib.context import CryptContext from passlib.context import CryptContext
from pydantic import BaseModel, BaseSettings, constr, validator from pydantic import BaseModel, BaseSettings, Field, validator
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
class Settings(BaseSettings): class Settings(BaseSettings):
@ -29,18 +31,18 @@ class Settings(BaseSettings):
production_mode: bool = False production_mode: bool = False
data_dir: Path = Path("./tmp") data_dir: Path = Path("./tmp")
config_file_name: Path = Path("config.json")
api_v1_prefix: str = "api/v1"
openapi_url: str = "/openapi.json" openapi_url: str = "/openapi.json"
docs_url: str | None = "/docs" docs_url: str | None = "/docs"
redoc_url: str | None = "/redoc" redoc_url: str | None = "/redoc"
@staticmethod
@functools.lru_cache
def get() -> Settings:
return Settings()
@property @property
def config_file(self) -> Path: def config_file(self) -> Path:
return self.data_dir.joinpath(self.config_file_name) return self.data_dir.joinpath("config.json")
SETTINGS = Settings()
class DBType(Enum): class DBType(Enum):
@ -61,20 +63,23 @@ class DBConfig(BaseModel):
user: str | None = None user: str | None = None
password: str | None = None password: str | None = None
host: str | None = None host: str | None = None
database: str | Path | None = SETTINGS.data_dir.joinpath("kiwi-vpn.db") database: str | None = Settings.get().data_dir.joinpath("vpn.db")
mysql_driver: str = "pymysql" mysql_driver: str = "pymysql"
mysql_args: list[str] = ["charset=utf8mb4"] mysql_args: list[str] = ["charset=utf8mb4"]
@property @property
def uri(self) -> str: async def db_engine(self) -> Engine:
""" """
Construct a database connection string Construct an SQLAlchemy engine
""" """
if self.type is DBType.sqlite: if self.type is DBType.sqlite:
# SQLite backend # SQLite backend
return f"sqlite:///{self.database}" return create_engine(
f"sqlite:///{self.database}",
connect_args={"check_same_thread": False},
)
elif self.type is DBType.mysql: elif self.type is DBType.mysql:
# MySQL backend # MySQL backend
@ -83,11 +88,12 @@ class DBConfig(BaseModel):
else: else:
args_str = "" args_str = ""
return (f"mysql+{self.mysql_driver}://" return create_engine(
f"{self.user}:{self.password}@{self.host}" f"mysql+{self.mysql_driver}://"
f"/{self.database}{args_str}") f"{self.user}:{self.password}@{self.host}"
f"/{self.database}{args_str}",
return "" pool_recycle=3600,
)
class JWTConfig(BaseModel): class JWTConfig(BaseModel):
@ -160,68 +166,22 @@ class JWTConfig(BaseModel):
return None return None
# get username # get username
return payload.get("sub") username = payload.get("sub")
if username is None:
return None
return username
class LockableString(BaseModel):
"""
A string that can be (logically) locked with an attached bool
"""
value: str
locked: bool
class LockableCountry(LockableString):
"""
Like `LockableString`, but with a `value` constrained two characters
"""
value: constr(max_length=2) # type: ignore
class ServerDN(BaseModel):
"""
This server's "distinguished name"
"""
country: LockableCountry
state: LockableString
city: LockableString
organization: LockableString
organizational_unit: LockableString
email: LockableString
common_name: str
class KeyAlgorithm(Enum):
"""
Supported certificate signing algorithms
"""
rsa2048 = "rsa2048"
rsa4096 = "rsa4096"
secp256r1 = "secp256r1"
secp384r1 = "secp384r1"
ed25519 = "ed25519"
class CryptoConfig(BaseModel): class CryptoConfig(BaseModel):
""" """
Configuration for cryptography Configuration for hash algorithms
""" """
# password hash algorithms
schemes: list[str] = ["bcrypt"] schemes: list[str] = ["bcrypt"]
# pki settings
key_algorithm: KeyAlgorithm | None
ca_password: str | None
ca_expiry_days: int | None
cert_expiry_days: int | None
@property @property
def context(self) -> CryptContext: async def crypt_context(self) -> CryptContext:
return CryptContext( return CryptContext(
schemes=self.schemes, schemes=self.schemes,
deprecated="auto", deprecated="auto",
@ -233,48 +193,27 @@ class Config(BaseModel):
Configuration for `kiwi-vpn-api` Configuration for `kiwi-vpn-api`
""" """
# may include client-to-client, cipher etc. db: DBConfig = Field(default_factory=DBConfig)
openvpn_extra_options: dict[str, Any] | None jwt: JWTConfig = Field(default_factory=JWTConfig)
crypto: CryptoConfig = Field(default_factory=CryptoConfig)
db: DBConfig @staticmethod
jwt: JWTConfig async def load() -> Config | None:
crypto: CryptoConfig
server_dn: ServerDN
__instance: Config | None = None
@classmethod
def load(cls) -> Config | None:
""" """
Load configuration from config file Load configuration from config file
""" """
if cls.__instance is None: try:
try: with open(Settings.get().config_file, "r") as config_file:
with open(SETTINGS.config_file, "r") as config_file: return Config.parse_obj(json.load(config_file))
cls.__instance = cls.parse_obj(json.load(config_file))
except FileNotFoundError: except FileNotFoundError:
return None return None
return cls.__instance async def save(self) -> None:
@classmethod
@property
def _(cls) -> Config:
"""
Shorthand for load(), but config file must exist
"""
if (config := cls.load()) is None:
raise FileNotFoundError(SETTINGS.config_file)
return config
def save(self) -> None:
""" """
Save configuration to config file Save configuration to config file
""" """
with open(SETTINGS.config_file, "w") as config_file: with open(Settings.get().config_file, "w") as config_file:
config_file.write(self.json(indent=2)) config_file.write(self.json(indent=2))

View file

@ -1,21 +1,4 @@
""" from . import models, schemas
Package `db`: ORM and schemas for database content.
"""
from .connection import Connection from .connection import Connection
from .device import Device, DeviceBase, DeviceCreate, DeviceRead
from .tag import TagValue
from .user import User, UserBase, UserCreate, UserRead
__all__ = [ __all__ = ["Connection", "models", "schemas"]
"Connection",
"Device",
"DeviceBase",
"DeviceCreate",
"DeviceRead",
"User",
"UserBase",
"UserCreate",
"UserRead",
"TagValue",
]

View file

@ -1,34 +1,75 @@
""" """
Database connection management. Utilities for handling SQLAlchemy database connections.
""" """
from sqlmodel import Session, SQLModel, create_engine from typing import Generator
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from .models import ORMBaseModel
class SessionManager:
"""
Simple context manager for an ORM session.
"""
__session: Session
def __init__(self, session: Session) -> None:
self.__session = session
def __enter__(self) -> Session:
return self.__session
def __exit__(self, *args) -> None:
self.__session.close()
class Connection: class Connection:
""" """
Namespace for the database connection Namespace for the database connection.
""" """
engine = None engine: Engine | None = None
session_local: sessionmaker | None = None
@classmethod @classmethod
def connect(cls, connection_url: str) -> None: def connect(cls, engine: Engine) -> None:
""" """
Connect ORM to a database engine. Connect ORM to a database engine.
""" """
cls.engine = create_engine(connection_url) cls.engine = engine
SQLModel.metadata.create_all(cls.engine) cls.session_local = sessionmaker(
autocommit=False, autoflush=False, bind=engine,
)
ORMBaseModel.metadata.create_all(bind=engine)
@classmethod @classmethod
@property def use(cls) -> SessionManager | None:
def session(cls) -> Session:
""" """
Create an ORM session using a context manager. Create an ORM session using a context manager.
""" """
if cls.engine is None: if cls.session_local is None:
raise ValueError("Not connected to database, can't create session") return None
return Session(cls.engine) return SessionManager(cls.session_local())
@classmethod
async def get(cls) -> Generator[Session | None, None, None]:
"""
Create an ORM session using a FastAPI compatible async generator.
"""
if cls.session_local is None:
yield None
else:
db = cls.session_local()
try:
yield db
finally:
db.close()

View file

@ -1,133 +0,0 @@
"""
Python representation of `device` table.
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy.exc import IntegrityError
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from .connection import Connection
if TYPE_CHECKING:
from .user import User
class DeviceStatus(Enum):
uncertified = "uncertified"
pending = "pending"
certified = "certified"
def __repr__(self) -> str:
return self.value
class DeviceBase(SQLModel):
"""
Common to all representations of devices
"""
name: str
type: str
class DeviceCreate(DeviceBase):
"""
Representation of a newly created device
"""
class DeviceRead(DeviceBase):
"""
Representation of a device read via the API
"""
id: int | None = Field(primary_key=True)
status_str: str = Field(default=repr(DeviceStatus.uncertified))
expiry: datetime | None = Field(default=None)
owner_name: str = Field(foreign_key="user.name")
@property
def status(self) -> DeviceStatus:
return DeviceStatus(self.status_str)
# property setters don't work with sqlmodel
def set_status(self, status: DeviceStatus) -> None:
self.status_str = repr(status)
class Device(DeviceRead, table=True):
"""
Representation of `device` table
"""
__table_args__ = (UniqueConstraint(
"owner_name",
"name",
),)
# no idea, but "User" (in quotes) doesn't work here
# might be a future problem?
owner: User = Relationship(
back_populates="devices",
sa_relationship_kwargs={
"lazy": "joined",
},
)
@classmethod
def create(
cls,
*,
owner: User,
device: DeviceCreate,
) -> Device | None:
"""
Create a new device in the database.
"""
try:
with Connection.session as db:
new_device = cls.from_orm(device, {"owner_name": owner.name})
db.add(new_device)
db.commit()
db.refresh(new_device)
return new_device
except IntegrityError:
# device already existed
return None
@classmethod
def get(cls, id: int) -> Device | None:
"""
Load device from database by id.
"""
with Connection.session as db:
return db.get(cls, id)
def update(self) -> None:
"""
Update this device in the database.
"""
with Connection.session as db:
db.add(self)
db.commit()
db.refresh(self)
def delete(self) -> None:
"""
Delete this device from the database.
"""
with Connection.session as db:
db.delete(self)
db.commit()

View file

@ -0,0 +1,106 @@
"""
SQLAlchemy representation of database contents.
"""
from __future__ import annotations
import datetime
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String,
UniqueConstraint)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, relationship
ORMBaseModel = declarative_base()
class User(ORMBaseModel):
__tablename__ = "users"
name = Column(String, primary_key=True, index=True)
password = Column(String, nullable=False)
capabilities: list[UserCapability] = relationship(
"UserCapability", lazy="joined", cascade="all, delete-orphan"
)
certificates: list[Certificate] = relationship(
"Certificate", lazy="select", back_populates="owner"
)
distinguished_names: list[DistinguishedName] = relationship(
"DistinguishedName", lazy="select", back_populates="owner"
)
@classmethod
def load(cls, db: Session, name: str) -> User | None:
"""
Load user from database by name.
"""
return (db
.query(User)
.filter(User.name == name)
.first())
class UserCapability(ORMBaseModel):
__tablename__ = "user_capabilities"
user_name = Column(
String,
ForeignKey("users.name"),
primary_key=True,
index=True,
)
capability = Column(String, primary_key=True)
class DistinguishedName(ORMBaseModel):
__tablename__ = "distinguished_names"
id = Column(Integer, primary_key=True, autoincrement=True)
owner_name = Column(String, ForeignKey("users.name"))
cn_only = Column(Boolean, default=True, nullable=False)
country = Column(String(2))
state = Column(String)
city = Column(String)
organization = Column(String)
organizational_unit = Column(String)
email = Column(String)
common_name = Column(String, nullable=False)
owner: User = relationship(
"User", lazy="joined", back_populates="distinguished_names"
)
UniqueConstraint(
country,
state,
city,
organization,
organizational_unit,
email,
common_name,
)
class Certificate(ORMBaseModel):
__tablename__ = "certificates"
id = Column(Integer, primary_key=True, autoincrement=True)
owner_name = Column(String, ForeignKey("users.name"))
dn_id = Column(
Integer,
ForeignKey("distinguished_names.id"),
nullable=False,
)
expiry = Column(DateTime, default=datetime.datetime.now)
distinguished_name: DistinguishedName = relationship(
"DistinguishedName", lazy="joined"
)
owner: User = relationship(
"User", lazy="joined", back_populates="certificates"
)

View file

@ -0,0 +1,275 @@
"""
Pydantic representation of database contents.
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any
from passlib.context import CryptContext
from pydantic import BaseModel, Field, constr, validator
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from . import models
##########
# table: distinguished_names
##########
class DistinguishedNameBase(BaseModel):
cn_only: bool
country: constr(max_length=2) | None
state: str | None
city: str | None
organization: str | None
organizational_unit: str | None
email: str | None
common_name: str
class DistinguishedNameCreate(DistinguishedNameBase):
pass
class DistinguishedName(DistinguishedNameBase):
class Config:
orm_mode = True
@classmethod
def create(
cls,
db: Session,
dn: DistinguishedNameCreate,
owner: User,
) -> User | None:
"""
Create a new distinguished name in the database.
"""
try:
db_owner = models.User.load(
db=db,
name=owner.name,
)
dn = models.DistinguishedName(
cn_only=dn.cn_only,
country=dn.country,
state=dn.state,
city=dn.city,
organization=dn.organization,
organizational_unit=dn.organizational_unit,
email=dn.email,
common_name=dn.common_name,
owner=db_owner,
)
db.add(dn)
db.commit()
db.refresh(dn)
return cls.from_orm(dn)
except IntegrityError:
# distinguished name already existed
pass
##########
# table: certificates
##########
class CertificateBase(BaseModel):
expiry: datetime
class CertificateCreate(CertificateBase):
pass
class Certificate(CertificateBase):
distinguished_name: DistinguishedName
class Config:
orm_mode = True
##########
# table: user_capabilities
##########
class UserCapability(Enum):
admin = "admin"
def __repr__(self) -> str:
return self.value
@classmethod
def from_value(cls, value) -> UserCapability:
"""
Create UserCapability from various formats
"""
if isinstance(value, cls):
# value is already a UserCapability, use that
return value
elif isinstance(value, models.UserCapability):
# create from db format
return cls(value.capability)
else:
# create from string representation
return cls(str(value))
##########
# table: users
##########
class UserBase(BaseModel):
name: str
class UserCreate(UserBase):
password: str
class User(UserBase):
capabilities: list[UserCapability] = []
distinguished_names: list[DistinguishedName] = Field(
default=[], repr=False
)
certificates: list[Certificate] = Field(
default=[], repr=False
)
class Config:
orm_mode = True
@validator("capabilities", pre=True)
@classmethod
def unify_capabilities(cls, value: list[Any]) -> list[UserCapability]:
"""
Import the capabilities from various formats
"""
return [
UserCapability.from_value(capability)
for capability in value
]
@classmethod
def from_db(
cls,
db: Session,
name: str,
) -> User | None:
"""
Load user from database by name.
"""
if (db_user := models.User.load(db, name)) is None:
return None
return cls.from_orm(db_user)
@classmethod
def create(
cls,
db: Session,
user: UserCreate,
crypt_context: CryptContext,
) -> User | None:
"""
Create a new user in the database.
"""
try:
user = models.User(
name=user.name,
password=crypt_context.hash(user.password),
capabilities=[],
)
db.add(user)
db.commit()
db.refresh(user)
return cls.from_orm(user)
except IntegrityError:
# user already existed
pass
def is_admin(self) -> bool:
return UserCapability.admin in self.capabilities
def authenticate(
self,
db: Session,
password: str,
crypt_context: CryptContext,
) -> User | None:
"""
Authenticate with name/password against users in database.
"""
if (db_user := models.User.load(db, self.name)) is None:
# nonexistent user, fake doing password verification
crypt_context.dummy_verify()
return False
if not crypt_context.verify(password, db_user.password):
# password hash mismatch
return False
self.from_orm(db_user)
return True
def update(
self,
db: Session,
) -> None:
"""
Update this user in the database.
"""
old_dbuser = models.User.load(db, self.name)
old_user = self.from_orm(old_dbuser)
for capability in self.capabilities:
if capability not in old_user.capabilities:
old_dbuser.capabilities.append(
models.UserCapability(capability=capability.value)
)
for capability in old_dbuser.capabilities:
if UserCapability.from_value(capability) not in self.capabilities:
db.delete(capability)
db.commit()
def delete(
self,
db: Session,
) -> bool:
"""
Delete this user from the database.
"""
if (db_user := models.User.load(db, self.name)) is None:
# nonexistent user
return False
db.delete(db_user)
db.commit()
return True

View file

@ -1,68 +0,0 @@
"""
Python representation of `tag` table.
"""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from .user import User
class TagValue(Enum):
"""
Allowed values for tags
"""
admin = "admin"
login = "login"
issue = "issue"
renew = "renew"
def __repr__(self) -> str:
return self.value
def _(self, user: User) -> Tag:
"""
Transform into a `Tag`.
"""
return Tag(
user_name=user.name,
tag_value=self.value,
)
class TagBase(SQLModel):
"""
Common to all representations of tags
"""
tag_value: str = Field(primary_key=True)
@property
def _(self) -> TagValue:
"""
Transform into a `TagValue`.
"""
return TagValue(self.tag_value)
def __repr__(self) -> str:
return self.tag_value
class Tag(TagBase, table=True):
"""
Representation of `tag` table
"""
user_name: str = Field(primary_key=True, foreign_key="user.name")
user: User = Relationship(
back_populates="tags",
)

View file

@ -1,309 +0,0 @@
"""
Python representation of `user` table.
"""
from __future__ import annotations
from typing import Any, Iterable, Sequence
from pydantic import root_validator
from sqlalchemy.exc import IntegrityError
from sqlmodel import Field, Relationship, SQLModel
from ..config import Config
from .connection import Connection
from .device import Device
from .tag import Tag, TagValue
class UserBase(SQLModel):
"""
Common to all representations of users
"""
name: str = Field(primary_key=True)
email: str
country: str | None = Field(default=None, max_length=2)
state: str | None = Field(default=None)
city: str | None = Field(default=None)
organization: str | None = Field(default=None)
organizational_unit: str | None = Field(default=None)
class UserCreate(UserBase):
"""
Representation of a newly created user
"""
password: str | None = Field(default=None)
password_clear: str | None = Field(default=None)
@root_validator
@classmethod
def hash_password(cls, values: dict[str, Any]) -> dict[str, Any]:
"""
Ensure the `password` value of this user gets set.
"""
if (values.get("password")) is not None:
# password is set
return values
if (password_clear := values.get("password_clear")) is None:
raise ValueError("No password to hash")
if (current_config := Config.load()) is None:
raise ValueError("Not configured")
values["password"] = current_config.crypto.context.hash(
password_clear)
return values
class UserRead(UserBase):
"""
Representation of a user read via the API
"""
pass
class User(UserBase, table=True):
"""
Representation of `user` table
"""
password: str
tags: list[Tag] = Relationship(
back_populates="user",
sa_relationship_kwargs={
"lazy": "joined",
"cascade": "all, delete-orphan",
},
)
devices: list[Device] = Relationship(
back_populates="owner",
)
@classmethod
def create(
cls,
*,
user: UserCreate,
) -> User | None:
"""
Create a new user in the database.
"""
try:
with Connection.session as db:
new_user = cls.from_orm(user)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
except IntegrityError:
# user already existed
return None
@classmethod
def get(cls, name: str) -> User | None:
"""
Load user from database by name.
"""
with Connection.session as db:
return db.get(cls, name)
@classmethod
def authenticate(
cls,
name: str,
password: str,
) -> User | None:
"""
Authenticate with name/password against users in database.
"""
crypt_context = Config._.crypto.context
if (user := cls.get(name)) is None:
# nonexistent user, fake doing password verification
crypt_context.dummy_verify()
return None
if not crypt_context.verify(password, user.password):
# password hash mismatch
return None
if not (user.has_tag(TagValue.login) or user.is_admin):
# no login permission
return None
return user
def update(self) -> None:
"""
Update this user in the database.
"""
with Connection.session as db:
db.add(self)
db.commit()
db.refresh(self)
def delete(self) -> None:
"""
Delete this user from the database.
"""
with Connection.session as db:
db.delete(self)
db.commit()
@property
def __tags(self) -> Iterable[TagValue]:
"""
Return the tags of this user.
"""
return (
tag._
for tag in self.tags
)
def has_tag(self, tag: TagValue) -> bool:
"""
Check if this user has a tag.
"""
return tag in self.__tags
@property
def is_admin(self) -> bool:
"""
Shorthand for checking if this user has the `admin` tag.
"""
return TagValue.admin in self.__tags
def add_tags(
self,
tags: Sequence[TagValue],
) -> None:
"""
Add tags to this user.
"""
self.tags = [
tag._(self)
for tag in (set(self.__tags) | set(tags))
]
def remove_tags(
self,
tags: Sequence[TagValue],
) -> None:
"""
remove tags from this user.
"""
self.tags = [
tag._(self)
for tag in (set(self.__tags) - set(tags))
]
def check_edit(
self,
target: User | Device,
) -> None:
"""
Check if this user can edit another user or a device.
"""
# admin can "edit" everything
if self.is_admin:
return None
# user can only "edit" itself
if isinstance(target, User) and target == self:
return None
# user can edit its owned devices
if isinstance(target, Device) and target.owner == self:
return None
# deny by default
raise PermissionError()
def check_admin(
self,
target: User | Device,
) -> None:
"""
Check if this user can administer another user or a device.
"""
# only admin can "admin" anything
if not self.is_admin:
raise PermissionError("Must be admin")
# admin cannot "admin" itself!
if isinstance(target, User) and target == self:
raise PermissionError("Can't administer self")
# admin can "admin" everything else
return None
def check_create(
self,
target: type,
owner: User | None = None,
) -> None:
"""
Check if this user can create another user or a device.
"""
# can never create anything but users or devices
if not issubclass(target, (User, Device)):
raise PermissionError(f"Cannot create target type {target}")
# admin can "create" everything
if self.is_admin:
return None
# user can only create devices for itself
if target is Device and owner == self:
return None
# deny by default
raise PermissionError()
@property
def can_issue(self) -> bool:
"""
Check if this user can issue a certificate without approval.
"""
return (
self.is_admin
or self.has_tag(TagValue.issue)
)
@property
def can_renew(self) -> bool:
"""
Check if this user can renew a certificate without approval.
"""
return (
self.is_admin
or self.has_tag(TagValue.renew)
)

View file

@ -1,198 +1,35 @@
"""
Python interface to EasyRSA CA.
"""
from __future__ import annotations
import subprocess import subprocess
from enum import Enum, auto from datetime import datetime
from pathlib import Path from pathlib import Path
from cryptography import x509 from OpenSSL import crypto
from passlib import pwd from passlib import pwd
from pydantic import BaseModel
from .config import SETTINGS, Config, KeyAlgorithm
from .db import Connection, Device
class DistinguishedName(BaseModel):
"""
An `X.509 distinguished name` (DN) as specified in RFC 5280
"""
country: str
state: str
city: str
organization: str
organizational_unit: str
email: str
common_name: str
@classmethod
def build(cls, device: Device | None = None) -> DistinguishedName:
"""
Create a DN from the current config and an optional device
"""
# extract server DN config
server_dn = Config._.server_dn
result = cls(
country=server_dn.country.value,
state=server_dn.state.value,
city=server_dn.city.value,
organization=server_dn.organization.value,
organizational_unit=server_dn.organizational_unit.value,
email=server_dn.email.value,
common_name=server_dn.common_name,
)
# no device specified -> done
if device is None:
return result
# don't override locked or empty fields
if not (server_dn.country.locked
or device.owner.country is None):
result.country = device.owner.country
if not (server_dn.state.locked
or device.owner.state is None):
result.state = device.owner.state
if not (server_dn.city.locked
or device.owner.city is None):
result.city = device.owner.city
if not (server_dn.organization.locked
or device.owner.organization is None):
result.organization = device.owner.organization
if not (server_dn.organizational_unit.locked
or device.owner.organizational_unit is None):
result.organizational_unit = device.owner.organizational_unit
# definitely use derived email and common_name
result.email = device.owner.email
result.common_name = f"{device.owner.name}_{device.name}"
return result
@property
def easyrsa_env(self) -> dict[str, str]:
"""
Pass this DN as arguments to easyrsa
"""
return {
"EASYRSA_DN": "org",
"EASYRSA_REQ_COUNTRY": self.country,
"EASYRSA_REQ_PROVINCE": self.state,
"EASYRSA_REQ_CITY": self.city,
"EASYRSA_REQ_ORG": self.organization,
"EASYRSA_REQ_OU": self.organizational_unit,
"EASYRSA_REQ_EMAIL": self.email,
"EASYRSA_REQ_CN": self.common_name,
}
class CertificateType(Enum):
"""
Possible types of certificates
"""
ca = auto()
client = auto()
server = auto()
def __str__(self) -> str:
return self._name_
class EasyRSA: class EasyRSA:
""" __directory: Path | None
Represents an EasyRSA PKI. __ca_password: str | None
"""
__mapKeyAlgorithm = { def __init__(self, directory: Path) -> None:
KeyAlgorithm.rsa2048: { self.__directory = directory
"EASYRSA_ALGO": "rsa",
"EASYRSA_KEY_SIZE": "2048",
},
KeyAlgorithm.rsa4096: {
"EASYRSA_ALGO": "rsa",
"EASYRSA_KEY_SIZE": "4096",
},
KeyAlgorithm.secp256r1: {
"EASYRSA_ALGO": "ec",
"EASYRSA_CURVE": "secp256r1",
},
KeyAlgorithm.secp384r1: {
"EASYRSA_ALGO": "ec",
"EASYRSA_CURVE": "secp384r1",
},
KeyAlgorithm.ed25519: {
"EASYRSA_ALGO": "ed",
"EASYRSA_CURVE": "ed25519",
},
None: {},
}
@property def set_ca_password(self, password: str | None = None) -> None:
def output_directory(self) -> Path: if password is None:
""" password = pwd.genword(length=32, charset="ascii_62")
Where certificates are stored
"""
return SETTINGS.data_dir.joinpath("pki") self.__ca_password = password
print(self.__ca_password)
@property
def ca_password(self) -> str:
"""
Get CA password from config, or generate a new one
"""
config = Config._
if (ca_password := config.crypto.ca_password) is None:
# generate and save new CA password
ca_password = pwd.genword(
length=32,
charset="ascii_62",
)
config.crypto.ca_password = ca_password
config.save()
return ca_password
def __easyrsa( def __easyrsa(
self, self,
*easyrsa_cmd: str, *easyrsa_args: str,
**easyrsa_env: str,
) -> subprocess.CompletedProcess: ) -> subprocess.CompletedProcess:
"""
Call the `easyrsa` executable
"""
return subprocess.run( return subprocess.run(
[ [
"/usr/local/bin/easyrsa", "easyrsa", "--batch",
*easyrsa_cmd, f"--pki-dir={self.__directory}",
*easyrsa_args,
], ],
env={
# base settings
"EASYRSA_BATCH": "1",
"EASYRSA_PKI": str(self.output_directory),
# always include CA password
"EASYRSA_PASSOUT": f"pass:{self.ca_password}",
"EASYRSA_PASSIN": f"pass:{self.ca_password}",
# include env from parameters
**easyrsa_env,
},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=True, check=True,
) )
@ -200,171 +37,82 @@ class EasyRSA:
def __build_cert( def __build_cert(
self, self,
cert_filename: Path, cert_filename: Path,
*easyrsa_cmd: str, *easyrsa_args: str,
**easyrsa_env: str, ) -> crypto.X509:
) -> x509.Certificate | None: self.__easyrsa(*easyrsa_args)
"""
Create an X.509 certificate
"""
config = Config._ with open(
self.__directory.joinpath(cert_filename), "r"
if ((algorithm := config.crypto.key_algorithm) ) as cert_file:
not in EasyRSA.__mapKeyAlgorithm): return crypto.load_certificate(
raise ValueError(f"Unexpected algorithm: {algorithm}") crypto.FILETYPE_PEM, cert_file.read()
# include expiry options
if (ca_expiry_days := config.crypto.ca_expiry_days) is not None:
easyrsa_env["EASYRSA_CA_EXPIRE"] = str(ca_expiry_days)
if (cert_expiry_days := config.crypto.cert_expiry_days) is not None:
easyrsa_env["EASYRSA_CERT_EXPIRE"] = str(cert_expiry_days)
try:
# call easyrsa
self.__easyrsa(
*easyrsa_cmd,
# include algorithm options
**EasyRSA.__mapKeyAlgorithm[algorithm],
**easyrsa_env,
) )
# parse the new certificate def init_pki(self) -> bool:
with open(
self.output_directory.joinpath(cert_filename), "rb"
) as cert_file:
return x509.load_pem_x509_certificate(
cert_file.read()
)
except (subprocess.CalledProcessError, FileNotFoundError):
# certificate couldn't be built
return None
def init_pki(self) -> None:
"""
Clean working directory
"""
self.__easyrsa("init-pki") self.__easyrsa("init-pki")
def build_ca(self) -> x509.Certificate: def build_ca(
""" self,
Build the CA certificate days: int = 365 * 50,
""" cn: str = "kiwi-vpn-ca"
) -> crypto.X509:
cert = self.__build_cert( cert = self.__build_cert(
Path("ca.crt"), Path("ca.crt"),
"build-ca",
EASYRSA_DN="cn_only", f"--passout=pass:{self.__ca_password}",
EASYRSA_REQ_CN="kiwi-vpn-ca", f"--passin=pass:{self.__ca_password}",
# "--dn-mode=org",
# "--req-c=EX",
# "--req-st=EXAMPLE",
# "--req-city=EXAMPLE",
# "--req-org=EXAMPLE",
# "--req-ou=EXAMPLE",
# "--req-email=EXAMPLE",
f"--req-cn={cn}",
f"--days={days}",
# "--use-algo=ed",
# "--curve=ed25519",
"build-ca",
) )
assert cert is not None self.__easyrsa("gen-dh")
# # this takes long!
# self.__easyrsa("gen-dh")
return cert return cert
def issue( def issue(
self, self,
cert_type: CertificateType = CertificateType.client, days: int = 365 * 50,
dn: DistinguishedName | None = None, cn: str = "kiwi-vpn-client",
) -> x509.Certificate | None: cert_type: str = "client"
""" ) -> crypto.X509:
Issue a client or server certificate
"""
if dn is None:
dn = DistinguishedName.build()
if not (cert_type is CertificateType.client
or cert_type is CertificateType.server):
return None
return self.__build_cert( return self.__build_cert(
Path("issued").joinpath(f"{dn.common_name}.crt"), Path(f"issued/{cn}.crt"),
f"--passin=pass:{self.__ca_password}",
f"--days={days}",
f"build-{cert_type}-full", f"build-{cert_type}-full",
dn.common_name, cn,
"nopass", "nopass",
**dn.easyrsa_env,
) )
def renew(
self,
dn: DistinguishedName | None = None,
) -> x509.Certificate | None:
"""
Renew a client or server certificate
"""
if dn is None:
dn = DistinguishedName.build()
return self.__build_cert(
Path("issued").joinpath(f"{dn.common_name}.crt"),
"renew",
dn.common_name,
"nopass",
# allow renewal 14 days before cert expiry
EASYRSA_CERT_RENEW="14",
**dn.easyrsa_env,
)
def revoke(
self,
dn: DistinguishedName | None = None,
) -> bool:
"""
Revoke a client or server certificate
"""
if dn is None:
dn = DistinguishedName.build()
try:
self.__easyrsa(
"revoke",
dn.common_name,
**dn.easyrsa_env,
)
except subprocess.CalledProcessError:
return False
return True
EASYRSA = EasyRSA()
# some basic test
if __name__ == "__main__": if __name__ == "__main__":
ca = EASYRSA.build_ca() easy_rsa = EasyRSA(Path("tmp/easyrsa"))
server = EASYRSA.issue(CertificateType.server) easy_rsa.init_pki()
client = None easy_rsa.set_ca_password()
# check if configured ca = easy_rsa.build_ca(cn="kiwi-vpn-ca")
if (current_config := Config.load()) is not None: server = easy_rsa.issue(cert_type="server", cn="kiwi-vpn-server")
# connect to database client = easy_rsa.issue(cert_type="client", cn="kiwi-vpn-client")
Connection.connect(current_config.db.uri)
if (device := Device.get(1)) is not None: date_format, encoding = "%Y%m%d%H%M%SZ", "ascii"
client = EASYRSA.issue(
dn=DistinguishedName.build(device)
)
for cert in (ca, server, client): for cert in [ca, server, client]:
if cert is not None: print(cert.get_subject().CN)
print(cert.subject) print(cert.get_signature_algorithm().decode(encoding))
print(cert.signature_hash_algorithm) print(datetime.strptime(
print(cert.not_valid_after) cert.get_notAfter().decode(encoding), date_format))

View file

@ -12,10 +12,14 @@ If run directly, uses `uvicorn` to run the app.
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from .config import SETTINGS, Config from .config import Config, Settings
from .db import Connection, User from .db import Connection
from .db.schemas import User
from .routers import main_router from .routers import main_router
settings = Settings.get()
app = FastAPI( app = FastAPI(
title="kiwi-vpn API", title="kiwi-vpn API",
description="This API enables the `kiwi-vpn` service.", description="This API enables the `kiwi-vpn` service.",
@ -27,9 +31,9 @@ app = FastAPI(
"name": "MIT License", "name": "MIT License",
"url": "https://opensource.org/licenses/mit-license.php", "url": "https://opensource.org/licenses/mit-license.php",
}, },
openapi_url=SETTINGS.openapi_url, openapi_url=settings.openapi_url,
docs_url=SETTINGS.docs_url if not SETTINGS.production_mode else None, docs_url=settings.docs_url if not settings.production_mode else None,
redoc_url=SETTINGS.redoc_url if not SETTINGS.production_mode else None, redoc_url=settings.redoc_url if not settings.production_mode else None,
) )
app.include_router(main_router) app.include_router(main_router)
@ -38,18 +42,19 @@ app.include_router(main_router)
@app.on_event("startup") @app.on_event("startup")
async def on_startup() -> None: async def on_startup() -> None:
# check if configured # check if configured
if (current_config := Config.load()) is not None: if (current_config := await Config.load()) is not None:
# connect to database # connect to database
Connection.connect(current_config.db.uri) Connection.connect(await current_config.db.db_engine)
# some testing # some testing
print(User.get("admin")) with Connection.use() as db:
print(User.get("nonexistent")) print(User.from_db(db, "admin"))
print(User.from_db(db, "nonexistent"))
def main() -> None: def main() -> None:
uvicorn.run( uvicorn.run(
app="kiwi_vpn_api.main:app", "kiwi_vpn_api.main:app",
host="0.0.0.0", host="0.0.0.0",
port=8000, port=8000,
reload=True, reload=True,

View file

@ -1,21 +1,10 @@
"""
Package `routers`: Each module contains the path operations for their prefixes.
This file: Main API router definition.
"""
from fastapi import APIRouter from fastapi import APIRouter
from ..config import SETTINGS from . import admin, user
from . import admin, device, service, user
main_router = APIRouter(prefix=f"/{SETTINGS.api_v1_prefix}") main_router = APIRouter(prefix="/api/v1")
main_router.include_router(admin.router) main_router.include_router(admin.router)
main_router.include_router(service.router)
main_router.include_router(device.router)
main_router.include_router(user.router) main_router.include_router(user.router)
__all__ = [ __all__ = ["main_router"]
"main_router",
]

View file

@ -2,15 +2,16 @@
Common dependencies for routers. Common dependencies for routers.
""" """
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from ..config import SETTINGS, Config from ..config import Config
from ..db import Device, User from ..db import Connection
from ..db.schemas import User
oauth2_scheme = OAuth2PasswordBearer( oauth2_scheme = OAuth2PasswordBearer(tokenUrl="user/authenticate")
tokenUrl=f"{SETTINGS.api_v1_prefix}/user/authenticate"
)
class Responses: class Responses:
@ -23,20 +24,24 @@ class Responses:
OK = { OK = {
"content": None, "content": None,
} }
INSTALLED = {
"description": "kiwi-vpn already installed",
"content": None,
}
NOT_INSTALLED = { NOT_INSTALLED = {
"description": "kiwi-vpn not installed", "description": "kiwi-vpn not installed",
"content": None, "content": None,
} }
NEEDS_USER = { NEEDS_USER = {
"description": "Not logged in", "description": "Must be logged in",
"content": None, "content": None,
} }
NEEDS_PERMISSION = { NEEDS_ADMIN = {
"description": "Operation not permitted", "description": "Must be admin",
"content": None, "content": None,
} }
ENTRY_ADDED = { NEEDS_ADMIN_OR_SELF = {
"description": "Entry added to database", "description": "Must be the requested user",
"content": None, "content": None,
} }
ENTRY_EXISTS = { ENTRY_EXISTS = {
@ -49,83 +54,69 @@ class Responses:
} }
async def get_current_config( async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session | None = Depends(Connection.get),
current_config: Config | None = Depends(Config.load), current_config: Config | None = Depends(Config.load),
) -> Config: ) -> User | None:
""" """
Get the current configuration if it exists. Get the currently logged-in user from the database.
Status:
- 400: `kiwi-vpn` not installed
""" """
# fail if not configured # can't connect to an unconfigured database
if current_config is None: if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
return current_config username = await current_config.jwt.decode_token(token)
user = User.from_db(db, username)
return user
async def get_current_user( async def get_current_user_if_exists(
token: str = Depends(oauth2_scheme), current_config: Config | None = Depends(Config.load),
current_config: Config = Depends(get_current_config), current_user: User | None = Depends(get_current_user),
) -> User: ) -> User:
""" """
Get the currently logged-in user if it exists. Get the currently logged-in user if it exists.
Status:
- (400: `kiwi-vpn` not installed)
- 401: No auth token provided/not logged in
- 403: invalid auth token, or user not found
""" """
# don't use error 404 here - possible user enumeration
# fail if not requested by a user # fail if not requested by a user
if (username := await current_config.jwt.decode_token(token)) is None: if current_user is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if (user := User.get(username)) is None: return current_user
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return user
async def get_user_by_name( async def get_current_user_if_admin(
user_name: str, current_config: Config | None = Depends(Config.load),
current_user: User = Depends(get_current_user_if_exists),
) -> User: ) -> User:
""" """
Get a user by name. Get the currently logged-in user if it is an admin.
Status:
- 403: user not found
""" """
# don't use error 404 here - possible user enumeration # fail if not requested by an admin
if not current_user.is_admin():
# fail if user doesn't exist
if (user := User.get(user_name)) is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return user return current_user
async def get_device_by_id( async def get_current_user_if_admin_or_self(
device_id: int, user_name: str,
) -> Device: current_config: Config | None = Depends(Config.load),
current_user: User = Depends(get_current_user_if_exists),
) -> User:
""" """
Get a device by ID. Get the currently logged-in user.
Status: Fails a) if the currently logged-in user is not the requested user,
and b) if it is not an admin.
- 404: device not found
""" """
# fail if device doesn't exist # fail if not requested by an admin or self
if (device := Device.get(device_id)) is None: if not (current_user.is_admin() or current_user.name == user_name):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return device return current_user

View file

@ -2,76 +2,51 @@
/admin endpoints. /admin endpoints.
""" """
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import select
from ..config import Config from ..config import Config
from ..db import Connection, TagValue, User, UserCreate from ..db import Connection
from ._common import Responses, get_current_config, get_current_user from ..db.schemas import User, UserCapability, UserCreate
from ._common import Responses, get_current_user
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@router.put( @router.put(
"/install/config", "/install",
responses={ responses={
status.HTTP_200_OK: Responses.OK, status.HTTP_200_OK: Responses.OK,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS, status.HTTP_400_BAD_REQUEST: Responses.INSTALLED,
}, },
) )
async def initial_configure( async def install(
config: Config, config: Config,
admin_user: UserCreate,
current_config: Config | None = Depends(Config.load), current_config: Config | None = Depends(Config.load),
): ):
""" """
PUT ./install/config: Configure `kiwi-vpn`. PUT ./install: Install `kiwi-vpn`.
Status:
- 409: `kiwi-vpn` already installed
""" """
# fail if already configured # fail if already installed
if current_config is not None: if current_config is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
# create config file, connect to database # create config file, connect to database
config.save() await config.save()
Connection.connect(config.db.uri) Connection.connect(await config.db.db_engine)
@router.put(
"/install/admin",
responses={
status.HTTP_201_CREATED: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
},
status_code=status.HTTP_201_CREATED,
)
async def create_initial_admin(
admin_user: UserCreate,
_: Config = Depends(get_current_config),
):
"""
PUT ./install/admin: Create the first administrative user.
Status:
- 409: not the first user
"""
# fail if any user exists
with Connection.session as db:
if db.exec(select(User).limit(1)).first() is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# create an administrative user # create an administrative user
if (new_user := User.create(user=admin_user)) is None: with Connection.use() as db:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) new_user = User.create(
db=db,
user=admin_user,
crypt_context=await config.crypto.crypt_context,
)
new_user.add_tags([TagValue.admin]) new_user.capabilities.append(UserCapability.admin)
new_user.update() new_user.update(db)
@router.put( @router.put(
@ -80,21 +55,27 @@ async def create_initial_admin(
status.HTTP_200_OK: Responses.OK, status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED, status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER, status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
}, },
) )
async def set_config( async def set_config(
config: Config, new_config: Config,
current_user: User = Depends(get_current_user), current_config: Config | None = Depends(Config.load),
current_user: User | None = Depends(get_current_user),
): ):
""" """
PUT ./config: Edit `kiwi-vpn` main config. PUT ./config: Edit `kiwi-vpn` main config.
""" """
# check permissions # fail if not installed
if not current_user.is_admin: if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
# fail if not requested by an admin
if (current_user is None
or UserCapability.admin not in current_user.capabilities):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
# update config file, reconnect to database # update config file, reconnect to database
config.save() await new_config.save()
Connection.connect(config.db.uri) Connection.connect(await new_config.db.db_engine)

View file

@ -1,244 +0,0 @@
"""
/device endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from kiwi_vpn_api.db.device import DeviceStatus
from ..db import Device, DeviceCreate, DeviceRead, User
from ..easyrsa import EASYRSA, DistinguishedName
from ._common import (Responses, get_current_user, get_device_by_id,
get_user_by_name)
router = APIRouter(prefix="/device", tags=["device"])
@router.post(
"/{user_name}",
responses={
status.HTTP_201_CREATED: Responses.ENTRY_ADDED,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
},
response_model=DeviceRead,
status_code=status.HTTP_201_CREATED,
)
async def add_device(
device: DeviceCreate,
current_user: User = Depends(get_current_user),
owner: User = Depends(get_user_by_name),
) -> Device:
"""
POST ./: Create a new device in the database.
Status:
- 403: no user permission to create device
- 409: device creation unsuccessful
"""
# check permissions
try:
current_user.check_create(Device, owner)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
# create the new device
new_device = Device.create(
owner=owner,
device=device,
)
# fail if creation was unsuccessful
if new_device is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# return the created device on success
return new_device
@router.delete(
"/{device_id}",
responses={
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
},
response_model=User,
)
async def remove_device(
current_user: User = Depends(get_current_user),
device: Device = Depends(get_device_by_id),
):
"""
DELETE ./{device_id}: Remove a device from the database.
Status:
- 403: no user permission to edit device
"""
# check permissions
try:
current_user.check_edit(device)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
# delete device
device.delete()
@router.post(
"/{device_id}/issue",
responses={
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
},
response_model=DeviceRead,
)
async def request_certificate_issuance(
current_user: User = Depends(get_current_user),
device: Device = Depends(get_device_by_id),
) -> Device:
"""
POST ./{device_id}/issue: Request certificate issuance for a device.
Status:
- 403: no user permission to edit device
- 409: device certificate cannot be "issued"
"""
# check permissions
try:
current_user.check_edit(device)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
# can only "request" on an uncertified device
if device.status is not DeviceStatus.uncertified:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
device.set_status(DeviceStatus.pending)
# check if we can issue the certificate immediately
if current_user.can_issue:
if (certificate := EASYRSA.issue(
dn=DistinguishedName.build(device)
)) is not None:
device.set_status(DeviceStatus.certified)
device.expiry = certificate.not_valid_after
# return updated device
device.update()
return device
@router.post(
"/{device_id}/renew",
responses={
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
},
response_model=DeviceRead,
)
async def request_certificate_renewal(
current_user: User = Depends(get_current_user),
device: Device = Depends(get_device_by_id),
) -> Device:
"""
POST ./{device_id}/renew: Request certificate renewal for a device.
Status:
- 403: no user permission to edit device
- 409: device certificate cannot be "renewed"
"""
# check permissions
try:
current_user.check_edit(device)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
# can only "renew" on an already certified device
if device.status is not DeviceStatus.certified:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
device.set_status(DeviceStatus.pending)
# check if we can renew the certificate immediately
if current_user.can_renew:
if (certificate := EASYRSA.renew(
dn=DistinguishedName.build(device)
)) is not None:
device.set_status(DeviceStatus.certified)
device.expiry = certificate.not_valid_after
# return updated device
device.update()
return device
@router.post(
"/{device_id}/revoke",
responses={
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
},
response_model=DeviceRead,
)
async def revoke_certificate(
current_user: User = Depends(get_current_user),
device: Device = Depends(get_device_by_id),
) -> Device:
"""
POST ./{device_id}/revoke: Revoke a device certificate.
Status:
- 403: no user permission to edit device
- 409: device certificate cannot be "revoked"
"""
# check permissions
try:
current_user.check_edit(device)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
# can only "revoke" on a currently certified device
if device.status is not DeviceStatus.certified:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# revoke the device certificate
EASYRSA.revoke(dn=DistinguishedName.build(device))
# reset the device
device.set_status(DeviceStatus.uncertified)
device.expiry = None
# return updated device
device.update()
return device

View file

@ -0,0 +1,58 @@
"""
/dn endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..db import Connection
from ..db.schemas import DistinguishedName, DistinguishedNameCreate, User
from ._common import Responses, get_current_user_if_admin_or_self
router = APIRouter(prefix="/dn")
@router.post(
"",
responses={
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
},
)
async def add_distinguished_name(
user_name: str,
distinguished_name: DistinguishedNameCreate,
_: User = Depends(get_current_user_if_admin_or_self),
db: Session | None = Depends(Connection.get),
):
"""
POST ./: Create a new distinguished name in the database.
"""
owner = User.from_db(
db=db,
name=user_name,
)
# fail if owner doesn't exist
if owner is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# actually create the new user
new_dn = DistinguishedName.create(
db=db,
dn=distinguished_name,
owner=owner,
)
# fail if creation was unsuccessful
if new_dn is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
# return the created user on success
return new_dn

View file

@ -1,34 +0,0 @@
"""
/service endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from ..db import User
from ..easyrsa import CertificateType, EasyRSA
from ._common import Responses, get_current_user
router = APIRouter(prefix="/service", tags=["service"])
@router.put(
"/pki/init",
responses={
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
},
)
async def init_pki(
current_user: User = Depends(get_current_user),
) -> None:
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
easy_rsa = EasyRSA()
easy_rsa.init_pki()
easy_rsa.build_ca()
easy_rsa.issue(CertificateType.server)

View file

@ -5,11 +5,12 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..config import Config from ..config import Config
from ..db import TagValue, User, UserCreate, UserRead from ..db import Connection
from ._common import (Responses, get_current_config, get_current_user, from ..db.schemas import User, UserCapability, UserCreate
get_user_by_name) from ._common import Responses, get_current_user, get_current_user_if_admin
router = APIRouter(prefix="/user", tags=["user"]) router = APIRouter(prefix="/user", tags=["user"])
@ -23,32 +24,27 @@ class Token(BaseModel):
token_type: str token_type: str
@router.post( @router.post("/authenticate", response_model=Token)
"/authenticate",
responses={
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
},
response_model=Token,
)
async def login( async def login(
form_data: OAuth2PasswordRequestForm = Depends(), form_data: OAuth2PasswordRequestForm = Depends(),
current_config: Config = Depends(get_current_config), current_config: Config | None = Depends(Config.load),
db: Session | None = Depends(Connection.get),
): ):
""" """
POST ./authenticate: Authenticate a user. Issues a bearer token. POST ./authenticate: Authenticate a user. Issues a bearer token.
Status:
- 401: username/password is incorrect
""" """
# fail if not installed
if current_config is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
# try logging in # try logging in
if (user := User.authenticate( user = User(name=form_data.username)
name=form_data.username, if not user.authenticate(
db=db,
password=form_data.password, password=form_data.password,
)) is None: crypt_context=await current_config.crypto.crypt_context,
):
# authentication failed # authentication failed
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@ -61,18 +57,9 @@ async def login(
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
@router.get( @router.get("/current", response_model=User)
"/current", async def get_current_user(
responses={ current_user: User | None = Depends(get_current_user),
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_USER,
},
response_model=UserRead,
)
async def get_current_user_route(
current_user: User = Depends(get_current_user),
): ):
""" """
GET ./current: Respond with the currently logged-in user. GET ./current: Respond with the currently logged-in user.
@ -84,45 +71,35 @@ async def get_current_user_route(
@router.post( @router.post(
"", "",
responses={ responses={
status.HTTP_201_CREATED: Responses.ENTRY_ADDED, status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED, status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER, status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS, status.HTTP_409_CONFLICT: Responses.ENTRY_EXISTS,
}, },
response_model=UserRead, response_model=User,
status_code=status.HTTP_201_CREATED,
) )
async def add_user( async def add_user(
user: UserCreate, user: UserCreate,
current_user: User = Depends(get_current_user), current_config: Config | None = Depends(Config.load),
) -> User: _: User = Depends(get_current_user_if_admin),
db: Session | None = Depends(Connection.get),
):
""" """
POST ./: Create a new user in the database. POST ./: Create a new user in the database.
Status:
- 403: no user permission to create user
- 409: user could not be created
""" """
# check permissions # actually create the new user
try: new_user = User.create(
current_user.check_create(User) db=db,
user=user,
except PermissionError as e: crypt_context=await current_config.crypto.crypt_context,
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e )
# create the new user
new_user = User.create(user=user)
# fail if creation was unsuccessful # fail if creation was unsuccessful
if new_user is None: if new_user is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT) raise HTTPException(status_code=status.HTTP_409_CONFLICT)
new_user.add_tags([TagValue.login])
new_user.update()
# return the created user on success # return the created user on success
return new_user return new_user
@ -133,97 +110,86 @@ async def add_user(
status.HTTP_200_OK: Responses.OK, status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED, status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER, status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
status.HTTP_404_NOT_FOUND: Responses.ENTRY_DOESNT_EXIST,
}, },
response_model=User, response_model=User,
) )
async def remove_user( async def remove_user(
current_user: User = Depends(get_current_user), user_name: str,
user: User = Depends(get_user_by_name), _: User = Depends(get_current_user_if_admin),
db: Session | None = Depends(Connection.get),
): ):
""" """
DELETE ./{user_name}: Remove a user from the database. DELETE ./{user_name}: Remove a user from the database.
Status:
- 403: no user permission to admin user
""" """
# check permissions # get the user
try: user = User.from_db(
current_user.check_admin(user) db=db,
name=user_name,
)
except PermissionError as e: # fail if deletion was unsuccessful
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e if user is None or not user.delete(db):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
# delete user
user.delete()
@router.post( @router.post(
"/{user_name}/tags", "/{user_name}/capabilities",
responses={
status.HTTP_201_CREATED: Responses.ENTRY_ADDED,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION,
},
status_code=status.HTTP_201_CREATED,
)
async def extend_tags(
tags: list[TagValue],
current_user: User = Depends(get_current_user),
user: User = Depends(get_user_by_name),
):
"""
POST ./{user_name}/tags: Add tags to a user.
Status:
- 403: no user permission to admin user
"""
# check permissions
try:
current_user.check_admin(user)
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e
# change user
user.add_tags(tags)
user.update()
@router.delete(
"/{user_name}/tags",
responses={ responses={
status.HTTP_200_OK: Responses.OK, status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED, status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER, status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_PERMISSION, status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
}, },
) )
async def remove_tags( async def extend_capabilities(
tags: list[TagValue], user_name: str,
current_user: User = Depends(get_current_user), capabilities: list[UserCapability],
user: User = Depends(get_user_by_name), _: User = Depends(get_current_user_if_admin),
db: Session | None = Depends(Connection.get),
): ):
""" """
DELETE ./{user_name}/tags: Remove tags from a user. POST ./{user_name}/capabilities: Add capabilities to a user.
Status:
- 403: no user permission to admin user
""" """
# check permissions # get and change the user
try: user = User.from_db(
current_user.check_admin(user) db=db,
name=user_name,
)
except PermissionError as e: user.capabilities.extend(capabilities)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) from e user.update(db)
# change user
user.remove_tags(tags) @router.delete(
user.update() "/{user_name}/capabilities",
responses={
status.HTTP_200_OK: Responses.OK,
status.HTTP_400_BAD_REQUEST: Responses.NOT_INSTALLED,
status.HTTP_401_UNAUTHORIZED: Responses.NEEDS_USER,
status.HTTP_403_FORBIDDEN: Responses.NEEDS_ADMIN,
},
)
async def remove_capabilities(
user_name: str,
capabilities: list[UserCapability],
_: User = Depends(get_current_user_if_admin),
db: Session | None = Depends(Connection.get),
):
"""
DELETE ./{user_name}/capabilities: Remove capabilities from a user.
"""
# get and change the user
user = User.from_db(
db=db,
name=user_name,
)
for capability in capabilities:
user.capabilities.remove(capability)
user.update(db)

View file

@ -1,46 +0,0 @@
## Server props
- default DN parts: country, state, city, org, OU
- "customizable" flags for DN parts
- flag: use client-to-client
- force cipher, tls-cipher, auth params
- server name
- default certification duration
- default certificate algo
## User props
- username (CN part)
- password
- custom DN parts: country, state, city, org, OU
- email (DN part)
- tags
## User tags
- admin: administrator
- login: can log into the web interface
- issue: can certify own devices (without approval)
- renew: can renew certificates for own devices (without approval)
## Device props
- name (CN part)
- type (icon)
- approved: bool
- expiry
## Device status
- created (approved = NULL): device has been newly created
- requested (approved = false): certificate has been requested (issue or renew)
- issued (approved = true): certificate has been granted (may be expired)
## Permissions
- admin cannot "admin" itself (to prevent self decapitation)
- admin can "edit", "admin" and "create" everything else
- user can "edit" itself and its devices
- user can "create" devices for itself
### User
- edit: change DN parts, password
- admin: add or remove tag, delete, generate password
### Device
- edit: change type, delete, request
- admin: approve

146
api/poetry.lock generated
View file

@ -108,11 +108,11 @@ pycparser = "*"
[[package]] [[package]]
name = "click" name = "click"
version = "8.1.2" version = "8.0.4"
description = "Composable command line interface toolkit" description = "Composable command line interface toolkit"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.6"
[package.dependencies] [package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""} colorama = {version = "*", markers = "platform_system == \"Windows\""}
@ -161,7 +161,7 @@ gmpy2 = ["gmpy2"]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.75.1" version = "0.75.0"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
category = "main" category = "main"
optional = false optional = false
@ -174,8 +174,8 @@ starlette = "0.17.1"
[package.extras] [package.extras]
all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"]
doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer (>=0.4.1,<0.5.0)", "pyyaml (>=5.3.1,<6.0.0)"] doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"]
test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==22.3.0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"] test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
@ -292,6 +292,21 @@ typing-extensions = ">=3.7.4.3"
dotenv = ["python-dotenv (>=0.10.4)"] dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"] email = ["email-validator (>=1.0.3)"]
[[package]]
name = "pyopenssl"
version = "22.0.0"
description = "Python wrapper module around the OpenSSL library"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
cryptography = ">=35.0"
[package.extras]
docs = ["sphinx", "sphinx-rtd-theme"]
test = ["flaky", "pretend", "pytest (>=3.0.1)"]
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.0.7" version = "3.0.7"
@ -383,7 +398,7 @@ python-versions = ">=3.5"
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "1.4.35" version = "1.4.32"
description = "Database Abstraction Library" description = "Database Abstraction Library"
category = "main" category = "main"
optional = false optional = false
@ -396,7 +411,7 @@ greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platfo
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"] aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"] aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
asyncio = ["greenlet (!=0.4.17)"] asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"] asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3)"]
mariadb_connector = ["mariadb (>=1.0.1)"] mariadb_connector = ["mariadb (>=1.0.1)"]
mssql = ["pyodbc"] mssql = ["pyodbc"]
mssql_pymssql = ["pymssql"] mssql_pymssql = ["pymssql"]
@ -413,30 +428,6 @@ postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql (<1)", "pymysql"] pymysql = ["pymysql (<1)", "pymysql"]
sqlcipher = ["sqlcipher3-binary"] sqlcipher = ["sqlcipher3-binary"]
[[package]]
name = "sqlalchemy2-stubs"
version = "0.0.2a21"
description = "Typing Stubs for SQLAlchemy 1.4"
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
typing-extensions = ">=3.7.4"
[[package]]
name = "sqlmodel"
version = "0.0.6"
description = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness."
category = "main"
optional = false
python-versions = ">=3.6.1,<4.0.0"
[package.dependencies]
pydantic = ">=1.8.2,<2.0.0"
SQLAlchemy = ">=1.4.17,<1.5.0"
sqlalchemy2-stubs = "*"
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.17.1" version = "0.17.1"
@ -486,7 +477,7 @@ standard = ["websockets (>=10.0)", "httptools (>=0.4.0)", "watchgod (>=0.6)", "p
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "36a56b6982734607590597302276605f8977119869934f35116e72377905b6b5" content-hash = "432d2933102f8a0091cec1b5484944a0211ca74c5dc9b65877d99d7bd160e4bb"
[metadata.files] [metadata.files]
anyio = [ anyio = [
@ -597,8 +588,8 @@ cffi = [
{file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"},
] ]
click = [ click = [
{file = "click-8.1.2-py3-none-any.whl", hash = "sha256:24e1a4a9ec5bf6299411369b208c1df2188d9eb8d916302fe6bf03faed227f1e"}, {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"},
{file = "click-8.1.2.tar.gz", hash = "sha256:479707fe14d9ec9a0757618b7a100a0ae4c4e236fac5b7f80ca68028141a1a72"}, {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"},
] ]
colorama = [ colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
@ -631,8 +622,8 @@ ecdsa = [
{file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"}, {file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"},
] ]
fastapi = [ fastapi = [
{file = "fastapi-0.75.1-py3-none-any.whl", hash = "sha256:f46f8fc81261c2bd956584114da9da98c84e2410c807bc2487532dabf55e7ab8"}, {file = "fastapi-0.75.0-py3-none-any.whl", hash = "sha256:43d12891b78fc497a50623e9c7c24640c569489f060acd9ce2c4902080487a93"},
{file = "fastapi-0.75.1.tar.gz", hash = "sha256:8b62bde916d657803fb60fffe88e2b2c9fb854583784607e4347681cae20ad01"}, {file = "fastapi-0.75.0.tar.gz", hash = "sha256:124774ce4cb3322841965f559669b233a0b8d343ea24fdd8b293253c077220d7"},
] ]
greenlet = [ greenlet = [
{file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"}, {file = "greenlet-1.1.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:58df5c2a0e293bf665a51f8a100d3e9956febfbf1d9aaf8c0677cf70218910c6"},
@ -775,6 +766,10 @@ pydantic = [
{file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"}, {file = "pydantic-1.9.0-py3-none-any.whl", hash = "sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3"},
{file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"}, {file = "pydantic-1.9.0.tar.gz", hash = "sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a"},
] ]
pyopenssl = [
{file = "pyOpenSSL-22.0.0-py2.py3-none-any.whl", hash = "sha256:ea252b38c87425b64116f808355e8da644ef9b07e429398bfece610f893ee2e0"},
{file = "pyOpenSSL-22.0.0.tar.gz", hash = "sha256:660b1b1425aac4a1bea1d94168a85d99f0b3144c869dd4390d27629d0087f1bf"},
]
pyparsing = [ pyparsing = [
{file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"},
{file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"},
@ -803,50 +798,41 @@ sniffio = [
{file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"},
] ]
sqlalchemy = [ sqlalchemy = [
{file = "SQLAlchemy-1.4.35-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:093b3109c2747d5dc0fa4314b1caf4c7ca336d5c8c831e3cfbec06a7e861e1e6"}, {file = "SQLAlchemy-1.4.32-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:4b2bcab3a914715d332ca783e9bda13bc570d8b9ef087563210ba63082c18c16"},
{file = "SQLAlchemy-1.4.35-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c6fb6b9ed1d0be7fa2c90be8ad2442c14cbf84eb0709dd1afeeff1e511550041"}, {file = "SQLAlchemy-1.4.32-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:159c2f69dd6efd28e894f261ffca1100690f28210f34cfcd70b895e0ea7a64f3"},
{file = "SQLAlchemy-1.4.35-cp27-cp27m-win32.whl", hash = "sha256:d38a49aa75a5759d0d118e26701d70c70a37b896379115f8386e91b0444bfa70"}, {file = "SQLAlchemy-1.4.32-cp27-cp27m-win_amd64.whl", hash = "sha256:d7e483f4791fbda60e23926b098702340504f7684ce7e1fd2c1bf02029288423"},
{file = "SQLAlchemy-1.4.35-cp27-cp27m-win_amd64.whl", hash = "sha256:70e571ae9ee0ff36ed37e2b2765445d54981e4d600eccdf6fe3838bc2538d157"}, {file = "SQLAlchemy-1.4.32-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4aa96e957141006181ca58e792e900ee511085b8dae06c2d08c00f108280fb8a"},
{file = "SQLAlchemy-1.4.35-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48036698f20080462e981b18d77d574631a3d1fc2c33b416c6df299ec1d10b99"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:576684771456d02e24078047c2567025f2011977aa342063468577d94e194b00"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:4ba2c1f368bcf8551cdaa27eac525022471015633d5bdafbc4297e0511f62f51"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff677fa4522dafb5a5e2c0cf909790d5d367326321aeabc0dffc9047cb235bd"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17316100fcd0b6371ac9211351cb976fd0c2e12a859c1a57965e3ef7f3ed2bc"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8679f9aba5ac22e7bce54ccd8a77641d3aea3e2d96e73e4356c887ebf8ff1082"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9837133b89ad017e50a02a3b46419869cf4e9aa02743e911b2a9e25fa6b05403"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7046f7aa2db445daccc8424f50b47a66c4039c9f058246b43796aa818f8b751"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4efb70a62cbbbc052c67dc66b5448b0053b509732184af3e7859d05fdf6223c"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-win32.whl", hash = "sha256:bedd89c34ab62565d44745212814e4b57ef1c24ad4af9b29c504ce40f0dc6558"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-win32.whl", hash = "sha256:1ff9f84b2098ef1b96255a80981ee10f4b5d49b6cfeeccf9632c2078cd86052e"}, {file = "SQLAlchemy-1.4.32-cp310-cp310-win_amd64.whl", hash = "sha256:199dc6d0068753b6a8c0bd3aceb86a3e782df118260ebc1fa981ea31ee054674"},
{file = "SQLAlchemy-1.4.35-cp310-cp310-win_amd64.whl", hash = "sha256:48f0eb5bcc87a9b2a95b345ed18d6400daaa86ca414f6840961ed85c342af8f4"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8e1e5d96b744a4f91163290b01045430f3f32579e46d87282449e5b14d27d4ac"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da25e75ba9f3fabc271673b6b413ca234994e6d3453424bea36bb5549c5bbaec"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edfcf93fd92e2f9eef640b3a7a40db20fe3c1d7c2c74faa41424c63dead61b76"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aeea6ace30603ca9a8869853bb4a04c7446856d7789e36694cd887967b7621f6"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04164e0063feb7aedd9d073db0fd496edb244be40d46ea1f0d8990815e4b8c34"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a5dbdbb39c1b100df4d182c78949158073ca46ba2850c64fe02ffb1eb5b70903"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ba59761c19b800bc2e1c9324da04d35ef51e4ee9621ff37534bc2290d258f71"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfd8e4c64c30a5219032e64404d468c425bdbc13b397da906fc9bee6591fc0dd"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-win32.whl", hash = "sha256:708973b5d9e1e441188124aaf13c121e5b03b6054c2df59b32219175a25aa13e"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-win32.whl", hash = "sha256:9dac1924611698f8fe5b2e58601156c01da2b6c0758ba519003013a78280cf4d"}, {file = "SQLAlchemy-1.4.32-cp36-cp36m-win_amd64.whl", hash = "sha256:316270e5867566376e69a0ac738b863d41396e2b63274616817e1d34156dff0e"},
{file = "SQLAlchemy-1.4.35-cp36-cp36m-win_amd64.whl", hash = "sha256:e8b09e2d90267717d850f2e2323919ea32004f55c40e5d53b41267e382446044"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:9a0195af6b9050c9322a97cf07514f66fe511968e623ca87b2df5e3cf6349615"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:63c82c9e8ccc2fb4bfd87c24ffbac320f70b7c93b78f206c1f9c441fa3013a5f"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e4a3c0c3c596296b37f8427c467c8e4336dc8d50f8ed38042e8ba79507b2c9"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:effadcda9a129cc56408dd5b2ea20ee9edcea24bd58e6a1489fa27672d733182"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bca714d831e5b8860c3ab134c93aec63d1a4f493bed20084f54e3ce9f0a3bf99"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2c6c411d8c59afba95abccd2b418f30ade674186660a2d310d364843049fb2c1"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9a680d9665f88346ed339888781f5236347933906c5a56348abb8261282ec48"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2489e70bfa2356f2d421106794507daccf6cc8711753c442fc97272437fc606"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-win32.whl", hash = "sha256:9cb5698c896fa72f88e7ef04ef62572faf56809093180771d9be8d9f2e264a13"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-win32.whl", hash = "sha256:186cb3bd77abf2ddcf722f755659559bfb157647b3fd3f32ea1c70e8311e8f6b"}, {file = "SQLAlchemy-1.4.32-cp37-cp37m-win_amd64.whl", hash = "sha256:8b9a395122770a6f08ebfd0321546d7379f43505882c7419d7886856a07caa13"},
{file = "SQLAlchemy-1.4.35-cp37-cp37m-win_amd64.whl", hash = "sha256:babd63fb7cb6b0440abb6d16aca2be63342a6eea3dc7b613bb7a9357dc36920f"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:3f88a4ee192142eeed3fe173f673ea6ab1f5a863810a9d85dbf6c67a9bd08f97"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9e1a72197529ea00357640f21d92ffc7024e156ef9ac36edf271c8335facbc1a"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd93162615870c976dba43963a24bb418b28448fef584f30755990c134a06a55"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e255a8dd5572b0c66d6ee53597d36157ad6cf3bc1114f61c54a65189f996ab03"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5a2e73508f939175363d8a4be9dcdc84cf16a92578d7fa86e6e4ca0e6b3667b2"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9bec63b1e20ef69484f530fb4b4837e050450637ff9acd6dccc7003c5013abf8"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfec934aac7f9fa95fc82147a4ba5db0a8bdc4ebf1e33b585ab8860beb10232f"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95411abc0e36d18f54fa5e24d42960ea3f144fb16caaa5a8c2e492b5424cc82c"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-win32.whl", hash = "sha256:bb42f9b259c33662c6a9b866012f6908a91731a419e69304e1261ba3ab87b8d1"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-win32.whl", hash = "sha256:28b17ebbaee6587013be2f78dc4f6e95115e1ec8dd7647c4e7be048da749e48b"}, {file = "SQLAlchemy-1.4.32-cp38-cp38-win_amd64.whl", hash = "sha256:7ff72b3cc9242d1a1c9b84bd945907bf174d74fc2519efe6184d6390a8df478b"},
{file = "SQLAlchemy-1.4.35-cp38-cp38-win_amd64.whl", hash = "sha256:9e7094cf04e6042c4210a185fa7b9b8b3b789dd6d1de7b4f19452290838e48bd"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5dc9801ae9884e822ba942ca493642fb50f049c06b6dbe3178691fce48ceb089"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:1b4eac3933c335d7f375639885765722534bb4e52e51cdc01a667eea822af9b6"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4607d2d16330757818c9d6fba322c2e80b4b112ff24295d1343a80b876eb0ed"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d8edfb09ed2b865485530c13e269833dab62ab2d582fde21026c9039d4d0e62"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:20e9eba7fd86ef52e0df25bea83b8b518dfdf0bce09b336cfe51671f52aaaa3f"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6204d06bfa85f87625e1831ca663f9dba91ac8aec24b8c65d02fb25cbaf4b4d7"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:290cbdf19129ae520d4bdce392648c6fcdbee763bc8f750b53a5ab51880cb9c9"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28aa2ef06c904729620cc735262192e622db9136c26d8587f71f29ec7715628a"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-win32.whl", hash = "sha256:1bbac3e8293b34c4403d297e21e8f10d2a57756b75cff101dc62186adec725f5"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-win32.whl", hash = "sha256:ecc81336b46e31ae9c9bdfa220082079914e31a476d088d3337ecf531d861228"}, {file = "SQLAlchemy-1.4.32-cp39-cp39-win_amd64.whl", hash = "sha256:b3f1d9b3aa09ab9adc7f8c4b40fc3e081eb903054c9a6f9ae1633fe15ae503b4"},
{file = "SQLAlchemy-1.4.35-cp39-cp39-win_amd64.whl", hash = "sha256:53c7469b86a60fe2babca4f70111357e6e3d5150373bc85eb3b914356983e89a"}, {file = "SQLAlchemy-1.4.32.tar.gz", hash = "sha256:6fdd2dc5931daab778c2b65b03df6ae68376e028a3098eb624d0909d999885bc"},
{file = "SQLAlchemy-1.4.35.tar.gz", hash = "sha256:2ffc813b01dc6473990f5e575f210ca5ac2f5465ace3908b78ffd6d20058aab5"},
]
sqlalchemy2-stubs = [
{file = "sqlalchemy2-stubs-0.0.2a21.tar.gz", hash = "sha256:207e3d8a36fc032d325f4eec89e0c6760efe81d07e978513d8c9b14f108dcd0c"},
{file = "sqlalchemy2_stubs-0.0.2a21-py3-none-any.whl", hash = "sha256:bd4a3d5ca7ff9d01b2245e1b26304d6b2ec4daf43a01faf40db9e09245679433"},
]
sqlmodel = [
{file = "sqlmodel-0.0.6-py3-none-any.whl", hash = "sha256:c5fd8719e09da348cd32ce2a5b6a44f289d3029fa8f1c9818229b6f34f1201b4"},
{file = "sqlmodel-0.0.6.tar.gz", hash = "sha256:3b4f966b9671b24d85529d274e6c4dbc7753b468e35d2d6a40bd75cad1f66813"},
] ]
starlette = [ starlette = [
{file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"}, {file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"},

View file

@ -1,19 +1,18 @@
[tool.poetry] [tool.poetry]
authors = ["Jörn-Michael Miehe <40151420+ldericher@users.noreply.github.com>"]
description = ""
name = "kiwi-vpn-api" name = "kiwi-vpn-api"
version = "0.1.0" version = "0.1.0"
description = ""
authors = ["Jörn-Michael Miehe <40151420+ldericher@users.noreply.github.com>"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
fastapi = "^0.75.0" fastapi = "^0.75.0"
passlib = {extras = ["argon2", "bcrypt"], version = "^1.7.4"}
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
python-multipart = "^0.0.5" python-multipart = "^0.0.5"
sqlmodel = "^0.0.6"
uvicorn = "^0.17.6" uvicorn = "^0.17.6"
cryptography = "^36.0.2" python-jose = {extras = ["cryptography"], version = "^3.3.0"}
passlib = {extras = ["argon2", "bcrypt"], version = "^1.7.4"}
SQLAlchemy = "^1.4.32"
pyOpenSSL = "^22.0.0"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^7.1.0" pytest = "^7.1.0"
@ -22,5 +21,5 @@ pytest = "^7.1.0"
kiwi-vpn-api = "kiwi_vpn_api.main:main" kiwi-vpn-api = "kiwi_vpn_api.main:main"
[build-system] [build-system]
build-backend = "poetry.core.masonry.api"
requires = ["poetry-core>=1.0.0"] requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"