diff --git a/.gitignore b/.gitignore index b0d6771..6479890 100644 --- a/.gitignore +++ b/.gitignore @@ -144,7 +144,7 @@ data/trash/ data/gtfs/ data/grfs data/tmp -data/agencyconf/** +data/users/** #these files are under app/static but they get copied to the outside directory on startup logging.conf diff --git a/README.md b/README.md index 329912b..bcac94d 100644 --- a/README.md +++ b/README.md @@ -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 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 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 - 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 resources and GET some public resources. diff --git a/amarillo/configuration.py b/amarillo/configuration.py index 019508f..6d2d66f 100644 --- a/amarillo/configuration.py +++ b/amarillo/configuration.py @@ -2,7 +2,7 @@ from amarillo.utils.container import container 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.regions import RegionService @@ -27,12 +27,12 @@ def create_required_directories(): assert_folder_exists(f'data/{subdir}/{agency_id}') # Agency configurations - assert_folder_exists(agency_conf_directory) + assert_folder_exists(user_conf_directory) def configure_services(): - container['agencyconf'] = AgencyConfService() - logger.info("Loaded %d agency configuration(s)", len(container['agencyconf'].agency_id_to_agency_conf)) + container['users'] = UserService() + logger.info("Loaded %d user configuration(s)", len(container['users'].user_id_to_user_conf)) container['agencies'] = AgencyService() logger.info("Loaded %d agencies", len(container['agencies'].agencies)) diff --git a/amarillo/main.py b/amarillo/main.py index ec1a257..ed3f074 100644 --- a/amarillo/main.py +++ b/amarillo/main.py @@ -12,7 +12,7 @@ copy_static_files(["conf", "static", "templates", "logging.conf", "config"]) import amarillo.plugins 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 from fastapi import FastAPI @@ -85,7 +85,7 @@ app = FastAPI(title="Amarillo - The Carpooling Intermediary", app.include_router(carpool.router) app.include_router(agency.router) -app.include_router(agencyconf.router) +app.include_router(users.router) app.include_router(region.router) app.include_router(oauth2.router) diff --git a/amarillo/models/AgencyConf.py b/amarillo/models/User.py similarity index 90% rename from amarillo/models/AgencyConf.py rename to amarillo/models/User.py index 2908eeb..efd20be 100644 --- a/amarillo/models/AgencyConf.py +++ b/amarillo/models/User.py @@ -1,9 +1,10 @@ from typing import Optional from pydantic import ConfigDict, BaseModel, Field +class User(BaseModel): + #TODO: add attributes admin, permissions, fullname, email -class AgencyConf(BaseModel): - agency_id: str = Field( + user_id: str = Field( description="ID of the agency that uses this token.", min_length=1, max_length=20, diff --git a/amarillo/routers/agency.py b/amarillo/routers/agency.py index 43cb130..a40d424 100644 --- a/amarillo/routers/agency.py +++ b/amarillo/routers/agency.py @@ -5,7 +5,7 @@ from typing import List from fastapi import APIRouter, HTTPException, status, Depends 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 # TODO should move this to service from amarillo.routers.carpool import store_carpool, delete_agency_carpools_older_than diff --git a/amarillo/routers/carpool.py b/amarillo/routers/carpool.py index c851b19..8dbd53c 100644 --- a/amarillo/routers/carpool.py +++ b/amarillo/routers/carpool.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Body, HTTPException, status, Depends from datetime import datetime 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.tests.sampledata import examples diff --git a/amarillo/routers/agencyconf.py b/amarillo/routers/users.py similarity index 57% rename from amarillo/routers/agencyconf.py rename to amarillo/routers/users.py index c08058b..ba43487 100644 --- a/amarillo/routers/agencyconf.py +++ b/amarillo/routers/users.py @@ -3,8 +3,8 @@ from typing import List from fastapi import APIRouter, HTTPException, status, Header, Depends -from amarillo.models.AgencyConf import AgencyConf -from amarillo.services.agencyconf import AgencyConfService +from amarillo.models.User import User +from amarillo.services.users import UserService from amarillo.services.oauth2 import get_current_agency, verify_admin from amarillo.services.config import config from amarillo.utils.container import container @@ -12,8 +12,8 @@ from amarillo.utils.container import container logger = logging.getLogger(__name__) router = APIRouter( - prefix="/agencyconf", - tags=["agencyconf"] + prefix="/users", + tags=["users"] ) # 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("/", include_in_schema=include_in_schema, - operation_id="getAgencyIdsWhichHaveAConfiguration", - summary="Get agency_ids which have a configuration", + operation_id="getUserIdsWhichHaveAConfiguration", + summary="Get user which have a configuration", 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) -async def get_agency_ids(admin_api_key: str = Depends(get_current_agency)) -> [str]: - return container['agencyconf'].get_agency_ids() +async def get_user_ids(admin_api_key: str = Depends(get_current_agency)) -> [str]: + return container['users'].get_user_ids() @router.post("/", include_in_schema=include_in_schema, - operation_id="postNewAgencyConf", - summary="Post a new AgencyConf") -async def post_agency_conf(agency_conf: AgencyConf, admin_api_key: str = Depends(verify_admin)): - agency_conf_service: AgencyConfService = container['agencyconf'] - agency_conf_service.add(agency_conf) + operation_id="postNewUserConf", + summary="Post a new User") +async def post_user_conf(user_conf: User, admin_api_key: str = Depends(verify_admin)): + user_service: UserService = container['users'] + user_service.add(user_conf) # TODO 400->403 -@router.delete("/{agency_id}", +@router.delete("/{user_id}", include_in_schema=include_in_schema, - operation_id="deleteAgencyConf", + operation_id="deleteUser", 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." ) -async def delete_agency_conf(agency_id: str, requesting_agency_id: str = Depends(get_current_agency)): - agency_may_delete_own = requesting_agency_id == agency_id - admin_may_delete_everything = requesting_agency_id == "admin" +async def delete_user(user_id: str, requesting_user_id: str = Depends(get_current_agency)): + agency_may_delete_own = requesting_user_id == user_id + admin_may_delete_everything = requesting_user_id == "admin" is_permitted = agency_may_delete_own or admin_may_delete_everything 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) 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: - message = f"No config for {agency_id}" + message = f"No config for {user_id}" logger.error(message) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) - agency_conf_service.delete(agency_id) + user_service.delete(user_id) diff --git a/amarillo/services/agencyconf.py b/amarillo/services/agencyconf.py deleted file mode 100644 index 86c879b..0000000 --- a/amarillo/services/agencyconf.py +++ /dev/null @@ -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}.") diff --git a/amarillo/services/oauth2.py b/amarillo/services/oauth2.py index 067411a..77332ec 100644 --- a/amarillo/services/oauth2.py +++ b/amarillo/services/oauth2.py @@ -11,7 +11,7 @@ from pydantic import BaseModel from amarillo.services.passwords import verify_password from amarillo.utils.container import container 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.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) def authenticate_agency(agency_id: str, password: str): - agency_conf_service : AgencyConfService = container['agencyconf'] - agency_conf = agency_conf_service.agency_id_to_agency_conf.get(agency_id, None) - if not agency_conf: + user_service : UserService = container['users'] + user_conf = user_service.user_id_to_user_conf.get(agency_id, None) + if not user_conf: return False - agency_password = agency_conf.password - if not verify_password(password, agency_password): + if not verify_password(password, user_conf.password): return False return agency_id @@ -103,9 +102,9 @@ async def verify_admin(agency: str = Depends(get_current_agency)): # noinspection PyPep8Naming # X_API_Key is upper case for OpenAPI 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") async def login_for_access_token( diff --git a/amarillo/services/users.py b/amarillo/services/users.py new file mode 100644 index 0000000..42338a8 --- /dev/null +++ b/amarillo/services/users.py @@ -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}.")