Renamed AgencyConf to User

This commit is contained in:
Csaba 2024-04-05 14:13:56 +02:00
parent 6f019020ea
commit 6af3250bea
11 changed files with 162 additions and 162 deletions

2
.gitignore vendored
View file

@ -144,7 +144,7 @@ data/trash/
data/gtfs/ data/gtfs/
data/grfs data/grfs
data/tmp data/tmp
data/agencyconf/** data/users/**
#these files are under app/static but they get copied to the outside directory on startup #these files are under app/static but they get copied to the outside directory on startup
logging.conf logging.conf

View file

@ -29,14 +29,14 @@ There is a special *admin* user.
For this user, the API-Key must be passed in as an environment variable when For this user, the API-Key must be passed in as an environment variable when
Amarillo is started. Amarillo is started.
The admin can create additional API-Keys in the `/agencyconf` endpoint. This The admin can create additional API-Keys in the `/users` endpoint. This
endpoint is always available but not always shown in `/docs`, especially not endpoint is always available but not always shown in `/docs`, especially not
when running in production. when running in production.
The Swagger docs for `/agencyconf` can be seen on the MFDZ demo server. The Swagger docs for `/users` can be seen on the MFDZ demo server.
Permissions work this way Permissions work this way
- the admin is allowed to call all operations on all resources. Only the admin - the admin is allowed to call all operations on all resources. Only the admin
can create new API-Keys by POSTing an `AgencyConf` JSON object to `/agencyconf`. can create new API-Keys by POSTing an `users` JSON object to `/users`.
- API-Keys for agencies are allowed to POST/PUT/GET/DELETE their own - API-Keys for agencies are allowed to POST/PUT/GET/DELETE their own
resources and GET some public resources. resources and GET some public resources.

View file

@ -2,7 +2,7 @@
from amarillo.utils.container import container from amarillo.utils.container import container
import logging import logging
from amarillo.services.agencyconf import AgencyConfService, agency_conf_directory from amarillo.services.users import UserService, user_conf_directory
from amarillo.services.agencies import AgencyService from amarillo.services.agencies import AgencyService
from amarillo.services.regions import RegionService from amarillo.services.regions import RegionService
@ -27,12 +27,12 @@ def create_required_directories():
assert_folder_exists(f'data/{subdir}/{agency_id}') assert_folder_exists(f'data/{subdir}/{agency_id}')
# Agency configurations # Agency configurations
assert_folder_exists(agency_conf_directory) assert_folder_exists(user_conf_directory)
def configure_services(): def configure_services():
container['agencyconf'] = AgencyConfService() container['users'] = UserService()
logger.info("Loaded %d agency configuration(s)", len(container['agencyconf'].agency_id_to_agency_conf)) logger.info("Loaded %d user configuration(s)", len(container['users'].user_id_to_user_conf))
container['agencies'] = AgencyService() container['agencies'] = AgencyService()
logger.info("Loaded %d agencies", len(container['agencies'].agencies)) logger.info("Loaded %d agencies", len(container['agencies'].agencies))

View file

@ -12,7 +12,7 @@ copy_static_files(["conf", "static", "templates", "logging.conf", "config"])
import amarillo.plugins import amarillo.plugins
from amarillo.configuration import configure_services, configure_admin_token from amarillo.configuration import configure_services, configure_admin_token
from amarillo.routers import carpool, agency, agencyconf, region from amarillo.routers import carpool, agency, region, users
import amarillo.services.oauth2 as oauth2 import amarillo.services.oauth2 as oauth2
from fastapi import FastAPI from fastapi import FastAPI
@ -85,7 +85,7 @@ app = FastAPI(title="Amarillo - The Carpooling Intermediary",
app.include_router(carpool.router) app.include_router(carpool.router)
app.include_router(agency.router) app.include_router(agency.router)
app.include_router(agencyconf.router) app.include_router(users.router)
app.include_router(region.router) app.include_router(region.router)
app.include_router(oauth2.router) app.include_router(oauth2.router)

View file

@ -1,9 +1,10 @@
from typing import Optional from typing import Optional
from pydantic import ConfigDict, BaseModel, Field from pydantic import ConfigDict, BaseModel, Field
class User(BaseModel):
#TODO: add attributes admin, permissions, fullname, email
class AgencyConf(BaseModel): user_id: str = Field(
agency_id: str = Field(
description="ID of the agency that uses this token.", description="ID of the agency that uses this token.",
min_length=1, min_length=1,
max_length=20, max_length=20,

View file

@ -5,7 +5,7 @@ from typing import List
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends
from amarillo.models.Carpool import Carpool, Agency from amarillo.models.Carpool import Carpool, Agency
from amarillo.routers.agencyconf import verify_permission_for_same_agency_or_admin from amarillo.routers.users import verify_permission_for_same_agency_or_admin
from amarillo.services.oauth2 import get_current_agency from amarillo.services.oauth2 import get_current_agency
# TODO should move this to service # TODO should move this to service
from amarillo.routers.carpool import store_carpool, delete_agency_carpools_older_than from amarillo.routers.carpool import store_carpool, delete_agency_carpools_older_than

View file

@ -9,7 +9,7 @@ from fastapi import APIRouter, Body, HTTPException, status, Depends
from datetime import datetime from datetime import datetime
from amarillo.models.Carpool import Carpool from amarillo.models.Carpool import Carpool
from amarillo.routers.agencyconf import verify_permission_for_same_agency_or_admin from amarillo.routers.users import verify_permission_for_same_agency_or_admin
from amarillo.services.oauth2 import get_current_agency from amarillo.services.oauth2 import get_current_agency
from amarillo.tests.sampledata import examples from amarillo.tests.sampledata import examples

View file

@ -3,8 +3,8 @@ from typing import List
from fastapi import APIRouter, HTTPException, status, Header, Depends from fastapi import APIRouter, HTTPException, status, Header, Depends
from amarillo.models.AgencyConf import AgencyConf from amarillo.models.User import User
from amarillo.services.agencyconf import AgencyConfService from amarillo.services.users import UserService
from amarillo.services.oauth2 import get_current_agency, verify_admin from amarillo.services.oauth2 import get_current_agency, verify_admin
from amarillo.services.config import config from amarillo.services.config import config
from amarillo.utils.container import container from amarillo.utils.container import container
@ -12,8 +12,8 @@ from amarillo.utils.container import container
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter( router = APIRouter(
prefix="/agencyconf", prefix="/users",
tags=["agencyconf"] tags=["users"]
) )
# This endpoint is not shown in PROD installations, only in development # This endpoint is not shown in PROD installations, only in development
@ -39,48 +39,48 @@ async def verify_permission_for_same_agency_or_admin(agency_id_in_path_or_body,
@router.get("/", @router.get("/",
include_in_schema=include_in_schema, include_in_schema=include_in_schema,
operation_id="getAgencyIdsWhichHaveAConfiguration", operation_id="getUserIdsWhichHaveAConfiguration",
summary="Get agency_ids which have a configuration", summary="Get user which have a configuration",
response_model=List[str], response_model=List[str],
description="Returns the agency_ids but not the details.", description="Returns the user_ids but not the details.",
status_code=status.HTTP_200_OK) status_code=status.HTTP_200_OK)
async def get_agency_ids(admin_api_key: str = Depends(get_current_agency)) -> [str]: async def get_user_ids(admin_api_key: str = Depends(get_current_agency)) -> [str]:
return container['agencyconf'].get_agency_ids() return container['users'].get_user_ids()
@router.post("/", @router.post("/",
include_in_schema=include_in_schema, include_in_schema=include_in_schema,
operation_id="postNewAgencyConf", operation_id="postNewUserConf",
summary="Post a new AgencyConf") summary="Post a new User")
async def post_agency_conf(agency_conf: AgencyConf, admin_api_key: str = Depends(verify_admin)): async def post_user_conf(user_conf: User, admin_api_key: str = Depends(verify_admin)):
agency_conf_service: AgencyConfService = container['agencyconf'] user_service: UserService = container['users']
agency_conf_service.add(agency_conf) user_service.add(user_conf)
# TODO 400->403 # TODO 400->403
@router.delete("/{agency_id}", @router.delete("/{user_id}",
include_in_schema=include_in_schema, include_in_schema=include_in_schema,
operation_id="deleteAgencyConf", operation_id="deleteUser",
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
summary="Delete configuration of an agency. Returns true if the token for the agency existed, " summary="Delete configuration of a user. Returns true if the token for the user existed, "
"false if it didn't exist." "false if it didn't exist."
) )
async def delete_agency_conf(agency_id: str, requesting_agency_id: str = Depends(get_current_agency)): async def delete_user(user_id: str, requesting_user_id: str = Depends(get_current_agency)):
agency_may_delete_own = requesting_agency_id == agency_id agency_may_delete_own = requesting_user_id == user_id
admin_may_delete_everything = requesting_agency_id == "admin" admin_may_delete_everything = requesting_user_id == "admin"
is_permitted = agency_may_delete_own or admin_may_delete_everything is_permitted = agency_may_delete_own or admin_may_delete_everything
if not is_permitted: if not is_permitted:
message = f"The API key for {requesting_agency_id} can not delete the configuration for {agency_id}" message = f"The API key for {requesting_user_id} can not delete the configuration for {user_id}"
logger.error(message) logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
agency_conf_service: AgencyConfService = container['agencyconf'] user_service: UserService = container['users']
agency_exists = agency_id in agency_conf_service.get_agency_ids() agency_exists = user_id in user_service.get_user_ids()
if not agency_exists: if not agency_exists:
message = f"No config for {agency_id}" message = f"No config for {user_id}"
logger.error(message) logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
agency_conf_service.delete(agency_id) user_service.delete(user_id)

View file

@ -1,115 +0,0 @@
import json
import os
from glob import glob
from typing import Dict, List
import logging
from fastapi import HTTPException, status
from amarillo.models.AgencyConf import AgencyConf
from amarillo.services.config import config
from amarillo.services.passwords import get_password_hash
logger = logging.getLogger(__name__)
agency_conf_directory = 'data/agencyconf'
class AgencyConfService:
def __init__(self):
# Both Dicts to be kept in sync always. The second api_key_to_agency_id is like a reverse
# cache for the first for fast lookup of valid api keys, which happens on *every* request.
self.agency_id_to_agency_conf: Dict[str, AgencyConf] = {}
self.api_key_to_agency_id: Dict[str, str] = {}
for agency_conf_file_name in glob(f'{agency_conf_directory}/*.json'):
with open(agency_conf_file_name) as agency_conf_file:
dictionary = json.load(agency_conf_file)
agency_conf = AgencyConf(**dictionary)
agency_id = agency_conf.agency_id
api_key = agency_conf.api_key
self.agency_id_to_agency_conf[agency_id] = agency_conf
if api_key is not None:
self.api_key_to_agency_id[api_key] = agency_conf.agency_id
def get_agency_conf(self, agency_id: str) -> AgencyConf:
agency_conf = self.agency_id_to_agency_conf.get(agency_id)
return agency_conf
def check_api_key(self, api_key: str) -> str:
"""Check if the API key is valid
The agencies' api keys are checked first, and the admin's key.
The agency_id or "admin" is returned for further checks in the caller if the
request is permitted, like {agency_id} == agency_id.
"""
agency_id = self.api_key_to_agency_id.get(api_key)
is_agency = agency_id is not None
if is_agency:
return agency_id
is_admin = api_key == config.admin_token
if is_admin:
return "admin"
message = "X-API-Key header invalid"
logger.error(message)
raise HTTPException(status_code=400, detail=message)
def add(self, agency_conf: AgencyConf):
agency_id = agency_conf.agency_id
api_key = agency_conf.api_key
agency_id_exists_already = self.agency_id_to_agency_conf.get(agency_id) is not None
if agency_id_exists_already:
message = f"Agency {agency_id} exists already. To update, delete it first."
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
agency_using_this_api_key_already = self.api_key_to_agency_id.get(api_key)
a_different_agency_is_using_this_api_key_already = \
agency_using_this_api_key_already is not None and \
agency_using_this_api_key_already != agency_id
if a_different_agency_is_using_this_api_key_already:
message = f"Duplicate API Key for {agency_id} not permitted. Use a different key."
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
agency_conf.password = get_password_hash(agency_conf.password)
with open(f'{agency_conf_directory}/{agency_id}.json', 'w', encoding='utf-8') as f:
f.write(agency_conf.json())
self.agency_id_to_agency_conf[agency_id] = agency_conf
self.api_key_to_agency_id[api_key] = agency_id
logger.info(f"Added configuration for agency {agency_id}.")
def get_agency_ids(self) -> List[str]:
return list(self.agency_id_to_agency_conf.keys())
def delete(self, agency_id):
agency_conf = self.agency_id_to_agency_conf.get(agency_id)
api_key = agency_conf.api_key
del self.api_key_to_agency_id[api_key]
del self.agency_id_to_agency_conf[agency_id]
os.remove(f'{agency_conf_directory}/{agency_id}.json')
logger.info(f"Deleted configuration for agency {agency_id}.")

View file

@ -11,7 +11,7 @@ from pydantic import BaseModel
from amarillo.services.passwords import verify_password from amarillo.services.passwords import verify_password
from amarillo.utils.container import container from amarillo.utils.container import container
from amarillo.services.agencies import AgencyService from amarillo.services.agencies import AgencyService
from amarillo.services.agencyconf import AgencyConfService from amarillo.services.users import UserService
from amarillo.models.Carpool import Agency from amarillo.models.Carpool import Agency
from amarillo.services.secrets import secrets from amarillo.services.secrets import secrets
@ -38,13 +38,12 @@ async def verify_optional_api_key(X_API_Key: Optional[str] = Header(None)):
return await verify_api_key(X_API_Key) return await verify_api_key(X_API_Key)
def authenticate_agency(agency_id: str, password: str): def authenticate_agency(agency_id: str, password: str):
agency_conf_service : AgencyConfService = container['agencyconf'] user_service : UserService = container['users']
agency_conf = agency_conf_service.agency_id_to_agency_conf.get(agency_id, None) user_conf = user_service.user_id_to_user_conf.get(agency_id, None)
if not agency_conf: if not user_conf:
return False return False
agency_password = agency_conf.password if not verify_password(password, user_conf.password):
if not verify_password(password, agency_password):
return False return False
return agency_id return agency_id
@ -103,9 +102,9 @@ async def verify_admin(agency: str = Depends(get_current_agency)):
# noinspection PyPep8Naming # noinspection PyPep8Naming
# X_API_Key is upper case for OpenAPI # X_API_Key is upper case for OpenAPI
async def verify_api_key(X_API_Key: str = Header(...)): async def verify_api_key(X_API_Key: str = Header(...)):
agency_conf_service: AgencyConfService = container['agencyconf'] user_service: UserService = container['users']
return agency_conf_service.check_api_key(X_API_Key) return user_service.check_api_key(X_API_Key)
@router.post("/token") @router.post("/token")
async def login_for_access_token( async def login_for_access_token(

115
amarillo/services/users.py Normal file
View file

@ -0,0 +1,115 @@
import json
import os
from glob import glob
from typing import Dict, List
import logging
from fastapi import HTTPException, status
from amarillo.models.User import User
from amarillo.services.config import config
from amarillo.services.passwords import get_password_hash
logger = logging.getLogger(__name__)
user_conf_directory = 'data/users'
class UserService:
def __init__(self):
# Both Dicts to be kept in sync always. The second api_key_to_agency_id is like a reverse
# cache for the first for fast lookup of valid api keys, which happens on *every* request.
self.user_id_to_user_conf: Dict[str, User] = {}
self.api_key_to_user_id: Dict[str, str] = {}
for user_conf_file_name in glob(f'{user_conf_directory}/*.json'):
with open(user_conf_file_name) as user_conf_file:
dictionary = json.load(user_conf_file)
user_conf = User(**dictionary)
agency_id = user_conf.user_id
api_key = user_conf.api_key
self.user_id_to_user_conf[agency_id] = user_conf
if api_key is not None:
self.api_key_to_user_id[api_key] = user_conf.user_id
def get_user(self, user_id: str) -> User:
user_conf = self.user_id_to_user_conf.get(user_id)
return user_conf
def check_api_key(self, api_key: str) -> str:
"""Check if the API key is valid
The agencies' api keys are checked first, and the admin's key.
The agency_id or "admin" is returned for further checks in the caller if the
request is permitted, like {agency_id} == agency_id.
"""
agency_id = self.api_key_to_user_id.get(api_key)
is_agency = agency_id is not None
if is_agency:
return agency_id
is_admin = api_key == config.admin_token
if is_admin:
return "admin"
message = "X-API-Key header invalid"
logger.error(message)
raise HTTPException(status_code=400, detail=message)
def add(self, user_conf: User):
user_id = user_conf.user_id
api_key = user_conf.api_key
agency_id_exists_already = self.user_id_to_user_conf.get(user_id) is not None
if agency_id_exists_already:
message = f"Agency {user_id} exists already. To update, delete it first."
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
agency_using_this_api_key_already = self.api_key_to_user_id.get(api_key)
a_different_agency_is_using_this_api_key_already = \
agency_using_this_api_key_already is not None and \
agency_using_this_api_key_already != user_id
if a_different_agency_is_using_this_api_key_already:
message = f"Duplicate API Key for {user_id} not permitted. Use a different key."
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
user_conf.password = get_password_hash(user_conf.password)
with open(f'{user_conf_directory}/{user_id}.json', 'w', encoding='utf-8') as f:
f.write(user_conf.json())
self.user_id_to_user_conf[user_id] = user_conf
self.api_key_to_user_id[api_key] = user_id
logger.info(f"Added configuration for user {user_id}.")
def get_user_ids(self) -> List[str]:
return list(self.user_id_to_user_conf.keys())
def delete(self, user_id):
user_conf = self.user_id_to_user_conf.get(user_id)
api_key = user_conf.api_key
del self.api_key_to_user_id[api_key]
del self.user_id_to_user_conf[user_id]
os.remove(f'{user_conf_directory}/{user_id}.json')
logger.info(f"Deleted configuration for {user_id}.")