Compare commits
1 commit
main
...
log-errors
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa25bd9189 |
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -144,8 +144,6 @@ data/trash/
|
||||||
data/gtfs/
|
data/gtfs/
|
||||||
data/grfs
|
data/grfs
|
||||||
data/tmp
|
data/tmp
|
||||||
data/users/**
|
|
||||||
data/**
|
|
||||||
|
|
||||||
#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
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ RUN \
|
||||||
|
|
||||||
ENV ADMIN_TOKEN=''
|
ENV ADMIN_TOKEN=''
|
||||||
ENV RIDE2GO_TOKEN=''
|
ENV RIDE2GO_TOKEN=''
|
||||||
ENV SECRET_KEY=''
|
|
||||||
ENV METRICS_USER=''
|
ENV METRICS_USER=''
|
||||||
ENV METRICS_PASSWORD=''
|
ENV METRICS_PASSWORD=''
|
||||||
|
|
||||||
|
|
|
||||||
2
Jenkinsfile
vendored
2
Jenkinsfile
vendored
|
|
@ -9,7 +9,7 @@ pipeline {
|
||||||
IMAGE_NAME = 'amarillo'
|
IMAGE_NAME = 'amarillo'
|
||||||
AMARILLO_DISTRIBUTION = '0.2'
|
AMARILLO_DISTRIBUTION = '0.2'
|
||||||
TAG = "${AMARILLO_DISTRIBUTION}.${BUILD_NUMBER}"
|
TAG = "${AMARILLO_DISTRIBUTION}.${BUILD_NUMBER}"
|
||||||
PLUGINS = 'amarillo-metrics amarillo-enhancer amarillo-grfs-exporter'
|
PLUGINS = 'amarillo-metrics amarillo-enhancer amarillo-grfs-export'
|
||||||
DEPLOY_WEBHOOK_URL = 'http://amarillo.mfdz.de:8888/mitanand'
|
DEPLOY_WEBHOOK_URL = 'http://amarillo.mfdz.de:8888/mitanand'
|
||||||
DEPLOY_SECRET = credentials('AMARILLO-JENKINS-DEPLOY-SECRET')
|
DEPLOY_SECRET = credentials('AMARILLO-JENKINS-DEPLOY-SECRET')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 `/users` endpoint. This
|
The admin can create additional API-Keys in the `/agencyconf` 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 `/users` can be seen on the MFDZ demo server.
|
The Swagger docs for `/agencyconf` 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 `users` JSON object to `/users`.
|
can create new API-Keys by POSTing an `AgencyConf` JSON object to `/agencyconf`.
|
||||||
- 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
from amarillo.utils.container import container
|
from amarillo.utils.container import container
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from amarillo.services.users import UserService, user_conf_directory
|
from amarillo.services.agencyconf import AgencyConfService, agency_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(user_conf_directory)
|
assert_folder_exists(agency_conf_directory)
|
||||||
|
|
||||||
|
|
||||||
def configure_services():
|
def configure_services():
|
||||||
container['users'] = UserService()
|
container['agencyconf'] = AgencyConfService()
|
||||||
logger.info("Loaded %d user configuration(s)", len(container['users'].user_id_to_user_conf))
|
logger.info("Loaded %d agency configuration(s)", len(container['agencyconf'].agency_id_to_agency_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))
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@ 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, region, users
|
from amarillo.routers import carpool, agency, agencyconf, region
|
||||||
import amarillo.services.oauth2 as oauth2
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi import FastAPI
|
from fastapi.responses import JSONResponse
|
||||||
|
import traceback
|
||||||
|
|
||||||
# https://pydantic-docs.helpmanual.io/usage/settings/
|
# https://pydantic-docs.helpmanual.io/usage/settings/
|
||||||
from amarillo.views import home
|
from amarillo.views import home
|
||||||
|
|
@ -85,10 +86,13 @@ 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(users.router)
|
app.include_router(agencyconf.router)
|
||||||
app.include_router(region.router)
|
app.include_router(region.router)
|
||||||
app.include_router(oauth2.router)
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def log_exception(request, exc):
|
||||||
|
logger.error(f"500 Error: {exc} \n{traceback.format_exc()}")
|
||||||
|
return JSONResponse(status_code=500, content={"message": "Internal Server Error"})
|
||||||
|
|
||||||
def iter_namespace(ns_pkg):
|
def iter_namespace(ns_pkg):
|
||||||
# Source: https://packaging.python.org/guides/creating-and-discovering-plugins/
|
# Source: https://packaging.python.org/guides/creating-and-discovering-plugins/
|
||||||
|
|
|
||||||
26
amarillo/models/AgencyConf.py
Normal file
26
amarillo/models/AgencyConf.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from pydantic import ConfigDict, BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AgencyConf(BaseModel):
|
||||||
|
agency_id: str = Field(
|
||||||
|
description="ID of the agency that uses this token.",
|
||||||
|
min_length=1,
|
||||||
|
max_length=20,
|
||||||
|
pattern='^[a-zA-Z0-9]+$',
|
||||||
|
examples=["mfdz"])
|
||||||
|
|
||||||
|
api_key: str = Field(
|
||||||
|
description="The agency's API key for using the API",
|
||||||
|
min_length=20,
|
||||||
|
max_length=256,
|
||||||
|
pattern=r'^[a-zA-Z0-9]+$',
|
||||||
|
examples=["d8yLuY4DqMEUCLcfJASi"])
|
||||||
|
model_config = ConfigDict(json_schema_extra={
|
||||||
|
"title": "Agency Configuration",
|
||||||
|
"description": "Configuration for an agency.",
|
||||||
|
"example":
|
||||||
|
{
|
||||||
|
"agency_id": "mfdz",
|
||||||
|
"api_key": "d8yLuY4DqMEUCLcfJASi"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
from typing import Annotated, Optional, List
|
|
||||||
from pydantic import ConfigDict, BaseModel, Field
|
|
||||||
class User(BaseModel):
|
|
||||||
#TODO: add attributes admin, permissions, fullname, email
|
|
||||||
|
|
||||||
user_id: str = Field(
|
|
||||||
description="ID of the agency that uses this token.",
|
|
||||||
min_length=1,
|
|
||||||
max_length=20,
|
|
||||||
pattern='^[a-zA-Z0-9]+$',
|
|
||||||
examples=["mfdz"])
|
|
||||||
|
|
||||||
api_key: Optional[str] = Field(None,
|
|
||||||
description="The agency's API key for using the API",
|
|
||||||
min_length=20,
|
|
||||||
max_length=256,
|
|
||||||
pattern=r'^[a-zA-Z0-9]+$',
|
|
||||||
examples=["d8yLuY4DqMEUCLcfJASi"])
|
|
||||||
password: Optional[str] = Field(None,
|
|
||||||
description="The agency's password for generating JWT tokens",
|
|
||||||
min_length=8,
|
|
||||||
max_length=256,
|
|
||||||
examples=["$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW"])
|
|
||||||
permissions: Optional[List[Annotated[str, Field(pattern=r'^[a-z0-9-]+(:[a-z]+)?$')]]] = Field([],
|
|
||||||
description="The permissions of this user, a list of strings in the format <agency:operation> or <operation>",
|
|
||||||
max_length=256,
|
|
||||||
# pattern=r'^[a-zA-Z0-9]+(:[a-zA-Z]+)?$', #TODO
|
|
||||||
examples=["ride2go:read", "all:read", "admin", "geojson"])
|
|
||||||
model_config = ConfigDict(json_schema_extra={
|
|
||||||
"title": "Agency Configuration",
|
|
||||||
"description": "Configuration for an agency.",
|
|
||||||
"example":
|
|
||||||
{
|
|
||||||
"agency_id": "mfdz",
|
|
||||||
"api_key": "d8yLuY4DqMEUCLcfJASi",
|
|
||||||
"password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -5,8 +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.models.User import User
|
from amarillo.routers.agencyconf import verify_api_key, verify_admin_api_key, verify_permission_for_same_agency_or_admin
|
||||||
from amarillo.services.oauth2 import get_current_user, verify_permission
|
|
||||||
# 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
|
||||||
from amarillo.services.agencies import AgencyService
|
from amarillo.services.agencies import AgencyService
|
||||||
|
|
@ -33,7 +32,7 @@ router = APIRouter(
|
||||||
status.HTTP_404_NOT_FOUND: {"description": "Agency not found"},
|
status.HTTP_404_NOT_FOUND: {"description": "Agency not found"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_agency(agency_id: str, requesting_user: User = Depends(get_current_user)) -> Agency:
|
async def get_agency(agency_id: str, admin_api_key: str = Depends(verify_api_key)) -> Agency:
|
||||||
agencies: AgencyService = container['agencies']
|
agencies: AgencyService = container['agencies']
|
||||||
agency = agencies.get_agency(agency_id)
|
agency = agencies.get_agency(agency_id)
|
||||||
agency_exists = agency is not None
|
agency_exists = agency is not None
|
||||||
|
|
@ -62,8 +61,8 @@ async def get_agency(agency_id: str, requesting_user: User = Depends(get_current
|
||||||
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
status.HTTP_500_INTERNAL_SERVER_ERROR: {
|
||||||
"description": "Import error"}
|
"description": "Import error"}
|
||||||
})
|
})
|
||||||
async def sync(agency_id: str, requesting_user: User = Depends(get_current_user)) -> List[Carpool]:
|
async def sync(agency_id: str, requesting_agency_id: str = Depends(verify_api_key)) -> List[Carpool]:
|
||||||
verify_permission(f"{agency_id}:sync")
|
await verify_permission_for_same_agency_or_admin(agency_id, requesting_agency_id)
|
||||||
|
|
||||||
if agency_id == "ride2go":
|
if agency_id == "ride2go":
|
||||||
import_function = import_ride2go
|
import_function = import_ride2go
|
||||||
|
|
|
||||||
103
amarillo/routers/agencyconf.py
Normal file
103
amarillo/routers/agencyconf.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import logging
|
||||||
|
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.services.config import config
|
||||||
|
from amarillo.utils.container import container
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/agencyconf",
|
||||||
|
tags=["agencyconf"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# This endpoint is not shown in PROD installations, only in development
|
||||||
|
# TODO make this an explicit config option
|
||||||
|
include_in_schema = config.env != 'PROD'
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyPep8Naming
|
||||||
|
# X_API_Key is upper case for OpenAPI
|
||||||
|
async def verify_admin_api_key(X_API_Key: str = Header(...)):
|
||||||
|
if X_API_Key != config.admin_token:
|
||||||
|
message="X-API-Key header invalid"
|
||||||
|
logger.error(message)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||||
|
|
||||||
|
return "admin"
|
||||||
|
|
||||||
|
|
||||||
|
# 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']
|
||||||
|
|
||||||
|
return agency_conf_service.check_api_key(X_API_Key)
|
||||||
|
|
||||||
|
# TODO Return code 403 Unauthoized (in response_status_codes as well...)
|
||||||
|
async def verify_permission_for_same_agency_or_admin(agency_id_in_path_or_body, agency_id_from_api_key):
|
||||||
|
"""Verifies that an agency is accessing something it owns or the user is admin
|
||||||
|
|
||||||
|
The agency_id is part of some paths, or when not in the path it is in the body, e.g. in PUT /carpool.
|
||||||
|
|
||||||
|
This function encapsulates the formula 'working with own stuff, or admin'.
|
||||||
|
"""
|
||||||
|
is_permitted = agency_id_in_path_or_body == agency_id_from_api_key or agency_id_from_api_key == "admin"
|
||||||
|
|
||||||
|
if not is_permitted:
|
||||||
|
message = f"Working with {agency_id_in_path_or_body} resources is not permitted for {agency_id_from_api_key}."
|
||||||
|
logger.error(message)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/",
|
||||||
|
include_in_schema=include_in_schema,
|
||||||
|
operation_id="getAgencyIdsWhichHaveAConfiguration",
|
||||||
|
summary="Get agency_ids which have a configuration",
|
||||||
|
response_model=List[str],
|
||||||
|
description="Returns the agency_ids but not the details.",
|
||||||
|
status_code=status.HTTP_200_OK)
|
||||||
|
async def get_agency_ids(admin_api_key: str = Depends(verify_api_key)) -> [str]:
|
||||||
|
return container['agencyconf'].get_agency_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_api_key)):
|
||||||
|
agency_conf_service: AgencyConfService = container['agencyconf']
|
||||||
|
agency_conf_service.add(agency_conf)
|
||||||
|
|
||||||
|
# TODO 400->403
|
||||||
|
@router.delete("/{agency_id}",
|
||||||
|
include_in_schema=include_in_schema,
|
||||||
|
operation_id="deleteAgencyConf",
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
summary="Delete configuration of an agency. Returns true if the token for the agency existed, "
|
||||||
|
"false if it didn't exist."
|
||||||
|
)
|
||||||
|
async def delete_agency_conf(agency_id: str, requesting_agency_id: str = Depends(verify_api_key)):
|
||||||
|
agency_may_delete_own = requesting_agency_id == agency_id
|
||||||
|
admin_may_delete_everything = requesting_agency_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}"
|
||||||
|
logger.error(message)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||||
|
|
||||||
|
agency_conf_service: AgencyConfService = container['agencyconf']
|
||||||
|
|
||||||
|
agency_exists = agency_id in agency_conf_service.get_agency_ids()
|
||||||
|
|
||||||
|
if not agency_exists:
|
||||||
|
message = f"No config for {agency_id}"
|
||||||
|
logger.error(message)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
||||||
|
|
||||||
|
agency_conf_service.delete(agency_id)
|
||||||
|
|
@ -5,12 +5,11 @@ import os.path
|
||||||
import re
|
import re
|
||||||
from glob import glob
|
from glob import glob
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException, status, Depends
|
from fastapi import APIRouter, Body, Header, 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.models.User import User
|
from amarillo.routers.agencyconf import verify_api_key, verify_permission_for_same_agency_or_admin
|
||||||
from amarillo.services.oauth2 import get_current_user, verify_permission
|
|
||||||
from amarillo.tests.sampledata import examples
|
from amarillo.tests.sampledata import examples
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -33,8 +32,8 @@ router = APIRouter(
|
||||||
|
|
||||||
})
|
})
|
||||||
async def post_carpool(carpool: Carpool = Body(..., examples=examples),
|
async def post_carpool(carpool: Carpool = Body(..., examples=examples),
|
||||||
requesting_user: User = Depends(get_current_user)) -> Carpool:
|
requesting_agency_id: str = Depends(verify_api_key)) -> Carpool:
|
||||||
verify_permission(f"{carpool.agency}:write", requesting_user)
|
await verify_permission_for_same_agency_or_admin(carpool.agency, requesting_agency_id)
|
||||||
|
|
||||||
logger.info(f"POST trip {carpool.agency}:{carpool.id}.")
|
logger.info(f"POST trip {carpool.agency}:{carpool.id}.")
|
||||||
await assert_agency_exists(carpool.agency)
|
await assert_agency_exists(carpool.agency)
|
||||||
|
|
@ -54,9 +53,7 @@ async def post_carpool(carpool: Carpool = Body(..., examples=examples),
|
||||||
status.HTTP_404_NOT_FOUND: {"description": "Carpool not found"},
|
status.HTTP_404_NOT_FOUND: {"description": "Carpool not found"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_carpool(agency_id: str, carpool_id: str, requesting_user: User = Depends(get_current_user)) -> Carpool:
|
async def get_carpool(agency_id: str, carpool_id: str, api_key: str = Depends(verify_api_key)) -> Carpool:
|
||||||
verify_permission(f"{agency_id}:read", requesting_user)
|
|
||||||
|
|
||||||
logger.info(f"Get trip {agency_id}:{carpool_id}.")
|
logger.info(f"Get trip {agency_id}:{carpool_id}.")
|
||||||
await assert_agency_exists(agency_id)
|
await assert_agency_exists(agency_id)
|
||||||
await assert_carpool_exists(agency_id, carpool_id)
|
await assert_carpool_exists(agency_id, carpool_id)
|
||||||
|
|
@ -75,8 +72,8 @@ async def get_carpool(agency_id: str, carpool_id: str, requesting_user: User = D
|
||||||
"description": "Carpool or agency not found"},
|
"description": "Carpool or agency not found"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def delete_carpool(agency_id: str, carpool_id: str, requesting_user: User = Depends(get_current_user)):
|
async def delete_carpool(agency_id: str, carpool_id: str, requesting_agency_id: str = Depends(verify_api_key)):
|
||||||
verify_permission(f"{agency_id}:write", requesting_user)
|
await verify_permission_for_same_agency_or_admin(agency_id, requesting_agency_id)
|
||||||
|
|
||||||
logger.info(f"Delete trip {agency_id}:{carpool_id}.")
|
logger.info(f"Delete trip {agency_id}:{carpool_id}.")
|
||||||
await assert_agency_exists(agency_id)
|
await assert_agency_exists(agency_id)
|
||||||
|
|
@ -137,7 +134,7 @@ async def save_carpool(carpool, folder: str = 'data/carpool'):
|
||||||
|
|
||||||
|
|
||||||
async def assert_agency_exists(agency_id: str):
|
async def assert_agency_exists(agency_id: str):
|
||||||
agency_exists = os.path.exists(f"data/agency/{agency_id}.json")
|
agency_exists = os.path.exists(f"conf/agency/{agency_id}.json")
|
||||||
if not agency_exists:
|
if not agency_exists:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ from typing import List
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
|
|
||||||
from amarillo.models.Carpool import Region
|
from amarillo.models.Carpool import Region
|
||||||
|
from amarillo.routers.agencyconf import verify_admin_api_key
|
||||||
from amarillo.services.regions import RegionService
|
from amarillo.services.regions import RegionService
|
||||||
from amarillo.utils.container import container
|
from amarillo.utils.container import container
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import logging
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status, Header, Depends
|
|
||||||
|
|
||||||
from amarillo.models.User import User
|
|
||||||
from amarillo.services.users import UserService
|
|
||||||
from amarillo.services.oauth2 import get_current_user, verify_permission
|
|
||||||
from amarillo.services.config import config
|
|
||||||
from amarillo.utils.container import container
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/users",
|
|
||||||
tags=["users"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# This endpoint is not shown in PROD installations, only in development
|
|
||||||
# TODO make this an explicit config option
|
|
||||||
include_in_schema = config.env != 'PROD'
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/",
|
|
||||||
include_in_schema=include_in_schema,
|
|
||||||
operation_id="getUserIdsWhichHaveAConfiguration",
|
|
||||||
summary="Get user which have a configuration",
|
|
||||||
response_model=List[str],
|
|
||||||
description="Returns the user_ids but not the details.",
|
|
||||||
status_code=status.HTTP_200_OK)
|
|
||||||
async def get_user_ids(requesting_user: User = Depends(get_current_user)) -> [str]:
|
|
||||||
return container['users'].get_user_ids()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/",
|
|
||||||
include_in_schema=include_in_schema,
|
|
||||||
operation_id="postNewUserConf",
|
|
||||||
summary="Post a new User")
|
|
||||||
async def post_user_conf(user_conf: User, requesting_user: User = Depends(get_current_user)):
|
|
||||||
verify_permission("admin", requesting_user)
|
|
||||||
user_service: UserService = container['users']
|
|
||||||
user_service.add(user_conf)
|
|
||||||
|
|
||||||
# TODO 400->403
|
|
||||||
@router.delete("/{user_id}",
|
|
||||||
include_in_schema=include_in_schema,
|
|
||||||
operation_id="deleteUser",
|
|
||||||
status_code=status.HTTP_200_OK,
|
|
||||||
summary="Delete configuration of a user. Returns true if the token for the user existed, "
|
|
||||||
"false if it didn't exist."
|
|
||||||
)
|
|
||||||
async def delete_user(user_id: str, requesting_user: User = Depends(get_current_user)):
|
|
||||||
user_may_delete_own = requesting_user.user_id == user_id
|
|
||||||
admin_may_delete_everything = "admin" in requesting_user.permissions
|
|
||||||
is_permitted = user_may_delete_own or admin_may_delete_everything
|
|
||||||
|
|
||||||
if not is_permitted:
|
|
||||||
message = f"User '{requesting_user.user_id} can not delete the configuration for {user_id}"
|
|
||||||
logger.error(message)
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
|
||||||
|
|
||||||
user_service: UserService = container['users']
|
|
||||||
|
|
||||||
agency_exists = user_id in user_service.get_user_ids()
|
|
||||||
|
|
||||||
if not agency_exists:
|
|
||||||
message = f"No config for {user_id}"
|
|
||||||
logger.error(message)
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
|
|
||||||
|
|
||||||
user_service.delete(user_id)
|
|
||||||
|
|
@ -12,7 +12,8 @@ class AgencyService:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.agencies: Dict[str, Agency] = {}
|
self.agencies: Dict[str, Agency] = {}
|
||||||
for agency_file_name in glob('data/agency/*.json'):
|
|
||||||
|
for agency_file_name in glob('conf/agency/*.json'):
|
||||||
with open(agency_file_name) as agency_file:
|
with open(agency_file_name) as agency_file:
|
||||||
dict = json.load(agency_file)
|
dict = json.load(agency_file)
|
||||||
agency = Agency(**dict)
|
agency = Agency(**dict)
|
||||||
|
|
|
||||||
111
amarillo/services/agencyconf.py
Normal file
111
amarillo/services/agencyconf.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
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}.")
|
||||||
|
|
@ -7,6 +7,6 @@ class Config(BaseSettings):
|
||||||
ride2go_query_data: str
|
ride2go_query_data: str
|
||||||
env: str = 'DEV'
|
env: str = 'DEV'
|
||||||
graphhopper_base_url: str = 'https://api.mfdz.de/gh'
|
graphhopper_base_url: str = 'https://api.mfdz.de/gh'
|
||||||
stop_sources_file: str = 'data/stop_sources.json'
|
stop_sources_file: str = 'conf/stop_sources.json'
|
||||||
|
|
||||||
config = Config(_env_file='config', _env_file_encoding='utf-8')
|
config = Config(_env_file='config', _env_file_encoding='utf-8')
|
||||||
|
|
|
||||||
|
|
@ -1,165 +0,0 @@
|
||||||
# OAuth2 authentication based on https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/#__tabbed_4_2
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Annotated, Optional, Union
|
|
||||||
import logging
|
|
||||||
import logging.config
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Header, status, APIRouter
|
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from amarillo.models.User import User
|
|
||||||
from amarillo.services.passwords import verify_password
|
|
||||||
from amarillo.utils.container import container
|
|
||||||
from amarillo.services.agencies import AgencyService
|
|
||||||
from amarillo.services.users import UserService
|
|
||||||
from amarillo.models.Carpool import Agency
|
|
||||||
|
|
||||||
from amarillo.services.secrets import secrets
|
|
||||||
|
|
||||||
SECRET_KEY = secrets.secret_key
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 7*24*60
|
|
||||||
|
|
||||||
logging.config.fileConfig('logging.conf', disable_existing_loggers=False)
|
|
||||||
logger = logging.getLogger("main")
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
class Token(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str
|
|
||||||
|
|
||||||
class TokenData(BaseModel):
|
|
||||||
user_id: Union[str, None] = None
|
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
|
|
||||||
async def verify_optional_api_key(X_API_Key: Optional[str] = Header(None)):
|
|
||||||
if X_API_Key == None: return None
|
|
||||||
return await verify_api_key(X_API_Key)
|
|
||||||
|
|
||||||
def authenticate_user(user_id: str, password: str):
|
|
||||||
user_service : UserService = container['users']
|
|
||||||
user_conf = user_service.user_id_to_user_conf.get(user_id, None)
|
|
||||||
if not user_conf:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not verify_password(password, user_conf.password):
|
|
||||||
return False
|
|
||||||
return user_id
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None):
|
|
||||||
to_encode = data.copy()
|
|
||||||
if expires_delta:
|
|
||||||
expire = datetime.now(timezone.utc) + expires_delta
|
|
||||||
else:
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
|
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
||||||
return encoded_jwt
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(token: str = Depends(oauth2_scheme), user_from_api_key: str = Depends(verify_optional_api_key)) -> User:
|
|
||||||
if token:
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate OAuth2 credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
||||||
user_id: str = payload.get("sub")
|
|
||||||
if user_id is None:
|
|
||||||
raise credentials_exception
|
|
||||||
token_data = TokenData(user_id=user_id)
|
|
||||||
except JWTError:
|
|
||||||
raise credentials_exception
|
|
||||||
user_id = token_data.user_id
|
|
||||||
if user_id is None:
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
user_service : UserService = container['users']
|
|
||||||
return user_service.get_user(user_id)
|
|
||||||
elif user_from_api_key:
|
|
||||||
logger.info(f"API Key provided: {user_from_api_key}")
|
|
||||||
user_service : UserService = container['users']
|
|
||||||
return user_service.get_user(user_from_api_key)
|
|
||||||
else:
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Not authenticated",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
def verify_permission(permission: str, user: User):
|
|
||||||
|
|
||||||
def permissions_exception():
|
|
||||||
return HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail=f"User '{user.user_id}' does not have the permission '{permission}'",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
#user is admin
|
|
||||||
if "admin" in user.permissions: return
|
|
||||||
|
|
||||||
#permission is an operation
|
|
||||||
if ":" not in permission:
|
|
||||||
if permission not in user.permissions:
|
|
||||||
raise permissions_exception()
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
#permission is in agency:operation format
|
|
||||||
def permission_matches(permission, user_permission):
|
|
||||||
prescribed_agency, prescribed_operation = permission.split(":")
|
|
||||||
given_agency, given_operation = user_permission.split(":")
|
|
||||||
|
|
||||||
return (prescribed_agency == given_agency or given_agency == "all") and (prescribed_operation == given_operation or given_operation == "all")
|
|
||||||
|
|
||||||
if any(permission_matches(permission, p) for p in user.permissions if ":" in p): return
|
|
||||||
|
|
||||||
raise permissions_exception()
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyPep8Naming
|
|
||||||
# X_API_Key is upper case for OpenAPI
|
|
||||||
async def verify_api_key(X_API_Key: str = Header(...)):
|
|
||||||
user_service: UserService = container['users']
|
|
||||||
|
|
||||||
return user_service.check_api_key(X_API_Key)
|
|
||||||
|
|
||||||
@router.post("/token")
|
|
||||||
async def login_for_access_token(
|
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
|
|
||||||
) -> Token:
|
|
||||||
agency = authenticate_user(form_data.username, form_data.password)
|
|
||||||
if not agency:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Incorrect username or password",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
access_token = create_access_token(
|
|
||||||
data={"sub": agency}, expires_delta=access_token_expires
|
|
||||||
)
|
|
||||||
return Token(access_token=access_token, token_type="bearer")
|
|
||||||
|
|
||||||
# TODO: eventually remove this
|
|
||||||
@router.get("/users/me/", response_model=Agency)
|
|
||||||
async def read_users_me(
|
|
||||||
current_agency: Annotated[Agency, Depends(get_current_user)]
|
|
||||||
):
|
|
||||||
agency_service : AgencyService = container['agencies']
|
|
||||||
return agency_service.get_agency(agency_id=current_agency)
|
|
||||||
|
|
||||||
# TODO: eventually remove this
|
|
||||||
@router.get("/users/me/items/")
|
|
||||||
async def read_own_items(
|
|
||||||
current_agency: Annotated[str, Depends(get_current_user)]
|
|
||||||
):
|
|
||||||
return [{"item_id": "Foo", "owner": current_agency}]
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
def verify_password(plain_password, hashed_password):
|
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password):
|
|
||||||
return pwd_context.hash(password)
|
|
||||||
|
|
@ -9,7 +9,8 @@ class RegionService:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.regions: Dict[str, Region] = {}
|
self.regions: Dict[str, Region] = {}
|
||||||
for region_file_name in glob('data/region/*.json'):
|
|
||||||
|
for region_file_name in glob('conf/region/*.json'):
|
||||||
with open(region_file_name) as region_file:
|
with open(region_file_name) as region_file:
|
||||||
dict = json.load(region_file)
|
dict = json.load(region_file)
|
||||||
region = Region(**dict)
|
region = Region(**dict)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
from pydantic import Field, ConfigDict
|
from typing import Dict
|
||||||
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import Optional
|
||||||
# Example: secrets = { "mfdz": "some secret" }
|
# Example: secrets = { "mfdz": "some secret" }
|
||||||
class Secrets(BaseSettings):
|
class Secrets(BaseSettings):
|
||||||
model_config = ConfigDict(extra='allow')
|
|
||||||
ride2go_token: str = Field(None, env = 'RIDE2GO_TOKEN')
|
ride2go_token: str = Field(None, env = 'RIDE2GO_TOKEN')
|
||||||
secret_key: str = Field(None, env = 'SECRET_KEY')
|
# TODO: define these as required if metrics plugin is installed
|
||||||
|
metrics_user: Optional[str] = Field(None, env = 'METRICS_USER')
|
||||||
|
metrics_password: Optional[str] = Field(None, env = 'METRICS_PASSWORD')
|
||||||
|
|
||||||
|
|
||||||
# Read if file exists, otherwise no error (it's in .gitignore)
|
# Read if file exists, otherwise no error (it's in .gitignore)
|
||||||
|
|
|
||||||
|
|
@ -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.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}.")
|
|
||||||
|
|
@ -25,4 +25,4 @@ formatter=simpleFormatter
|
||||||
args=('error.log', 'a', 1000000, 3) # Filename, mode, maxBytes, backupCount
|
args=('error.log', 'a', 1000000, 3) # Filename, mode, maxBytes, backupCount
|
||||||
|
|
||||||
[formatter_simpleFormatter]
|
[formatter_simpleFormatter]
|
||||||
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
|
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
from fastapi import HTTPException
|
|
||||||
import pytest
|
|
||||||
from amarillo.services.oauth2 import verify_permission
|
|
||||||
from amarillo.models.User import User
|
|
||||||
|
|
||||||
test_user = User(user_id="test", password="testpassword", permissions=["all:read", "mfdz:write", "ride2go:all", "gtfs"])
|
|
||||||
admin_user = User(user_id="admin", password="testpassword", permissions=["admin"])
|
|
||||||
|
|
||||||
def test_operation():
|
|
||||||
verify_permission("gtfs", test_user)
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException):
|
|
||||||
verify_permission("geojson", test_user)
|
|
||||||
|
|
||||||
def test_agency_permission():
|
|
||||||
verify_permission("mvv:read", test_user)
|
|
||||||
verify_permission("mfdz:read", test_user)
|
|
||||||
verify_permission("mfdz:write", test_user)
|
|
||||||
verify_permission("ride2go:write", test_user)
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException):
|
|
||||||
verify_permission("mvv:write", test_user)
|
|
||||||
verify_permission("mvv:all", test_user)
|
|
||||||
|
|
||||||
|
|
||||||
def test_admin():
|
|
||||||
verify_permission("admin", admin_user)
|
|
||||||
verify_permission("gtfs", admin_user)
|
|
||||||
verify_permission("all:all", admin_user)
|
|
||||||
verify_permission("mvv:all", admin_user)
|
|
||||||
verify_permission("mfdz:read", admin_user)
|
|
||||||
verify_permission("mfdz:write", admin_user)
|
|
||||||
verify_permission("ride2go:write", admin_user)
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException):
|
|
||||||
verify_permission("admin", test_user)
|
|
||||||
verify_permission("all:all", test_user)
|
|
||||||
|
|
@ -16,9 +16,6 @@ dependencies = [
|
||||||
"pyproj==3.6.1",
|
"pyproj==3.6.1",
|
||||||
"geojson-pydantic==1.0.1",
|
"geojson-pydantic==1.0.1",
|
||||||
"watchdog==3.0.0",
|
"watchdog==3.0.0",
|
||||||
"python-jose[cryptography]",
|
|
||||||
"bcrypt==4.0.1",
|
|
||||||
"passlib[bcrypt]"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages]
|
[tool.setuptools.packages]
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,4 @@ starlette~=0.35
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
pyproj==3.6.1
|
pyproj==3.6.1
|
||||||
geojson-pydantic==1.0.1
|
geojson-pydantic==1.0.1
|
||||||
pytest
|
pytest
|
||||||
python-jose[cryptography]
|
|
||||||
bcrypt==4.0.1
|
|
||||||
passlib[bcrypt]
|
|
||||||
Loading…
Reference in a new issue