Skip to content

Commit

Permalink
feat(backend): add integrations router with API key functionality (…
Browse files Browse the repository at this point in the history
…external apps) (#2110)

* build: add migration for users.api_key field

* feat: add integrations router with external app api key access
  • Loading branch information
spwoodcock authored Jan 24, 2025
1 parent 4a3a277 commit 14559e1
Show file tree
Hide file tree
Showing 10 changed files with 232 additions and 4 deletions.
6 changes: 3 additions & 3 deletions src/backend/app/auth/auth_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"""Auth dependencies, for restricted routes and cookie handling."""

from time import time
from typing import Optional
from typing import Annotated, Optional

import jwt
from fastapi import Header, HTTPException, Request, Response
Expand Down Expand Up @@ -262,7 +262,7 @@ async def refresh_cookies(


async def login_required(
request: Request, access_token: str = Header(None)
request: Request, access_token: Annotated[Optional[str], Header()] = None
) -> AuthUser:
"""Dependency for endpoints requiring login."""
if settings.DEBUG:
Expand All @@ -277,7 +277,7 @@ async def login_required(


async def mapper_login_required(
request: Request, access_token: str = Header(None)
request: Request, access_token: Annotated[Optional[str], Header()] = None
) -> AuthUser:
"""Dependency for mapper frontend login."""
if settings.DEBUG:
Expand Down
1 change: 1 addition & 0 deletions src/backend/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ class DbUser(BaseModel):
tasks_validated: Optional[int] = None
tasks_invalidated: Optional[int] = None
projects_mapped: Optional[list[int]] = None
api_key: Optional[str] = None
registered_at: Optional[AwareDatetime] = None

# Relationships
Expand Down
1 change: 1 addition & 0 deletions src/backend/app/integrations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""FMTM integrations API, for linking to external services."""
50 changes: 50 additions & 0 deletions src/backend/app/integrations/integration_crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Copyright (c) Humanitarian OpenStreetMap Team
#
# This file is part of FMTM.
#
# FMTM is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# FMTM is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with FMTM. If not, see <https:#www.gnu.org/licenses/>.
#
"""Logic for integration routes."""

from secrets import token_urlsafe

from loguru import logger as log
from psycopg import Connection
from psycopg.rows import class_row

from app.db.models import DbUser


async def generate_api_token(
db: Connection,
user_id: int,
) -> str:
"""Generate a new API token for a given user."""
async with db.cursor(row_factory=class_row(DbUser)) as cur:
await cur.execute(
"""
UPDATE users
SET api_key = %(api_key)s
WHERE id = %(user_id)s
RETURNING *;
""",
{"user_id": user_id, "api_key": token_urlsafe(32)},
)
db_user = await cur.fetchone()
if not db_user.api_key:
msg = f"Failed to generate API Key for user ({user_id})"
log.error(msg)
raise ValueError(msg)

return db_user.api_key
65 changes: 65 additions & 0 deletions src/backend/app/integrations/integration_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright (c) Humanitarian OpenStreetMap Team
#
# This file is part of FMTM.
#
# FMTM is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# FMTM is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with FMTM. If not, see <https:#www.gnu.org/licenses/>.
#

"""Integration dependencies, for API token validation."""

from typing import Annotated

from fastapi import (
Depends,
Header,
)
from fastapi.exceptions import HTTPException
from psycopg import Connection
from psycopg.rows import class_row

from app.db.database import db_conn
from app.db.enums import HTTPStatus
from app.db.models import DbUser


async def valid_api_token(
db: Annotated[Connection, Depends(db_conn)],
x_api_key: Annotated[str, Header()],
) -> DbUser:
"""Check the API token is present for an active database user.
A header X-API-Key must be provided in the request.
TODO currently this only checks for a valid key, but does not
TODO include checking roles.
TODO If roles other than 'mapper' are required, this should be integrated.
"""
async with db.cursor(row_factory=class_row(DbUser)) as cur:
await cur.execute(
"""
SELECT *
FROM users
WHERE api_key = %(api_key)s
AND is_email_verified = TRUE;
""",
{"api_key": x_api_key},
)
db_user = await cur.fetchone()
if not db_user:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
detail=f"API key invalid: ({x_api_key})",
)

return db_user
98 changes: 98 additions & 0 deletions src/backend/app/integrations/integration_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copyright (c) Humanitarian OpenStreetMap Team
#
# This file is part of FMTM.
#
# FMTM is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# FMTM is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with FMTM. If not, see <https:#www.gnu.org/licenses/>.
#
"""Routes to integrate with external apps, via an API key.
We handle these endpoints separately to minimise the attach surface
possible from misused API keys (so the entire API is not accessible).
API keys are inherently not as secure as OAuth flow / JWT token combo.
"""

from typing import Annotated

from fastapi import (
APIRouter,
Depends,
)
from fastapi.exceptions import HTTPException
from fastapi.responses import JSONResponse
from psycopg import Connection

from app.auth.roles import super_admin
from app.central.central_crud import update_entity_mapping_status
from app.central.central_schemas import EntityMappingStatus, EntityMappingStatusIn
from app.db.database import db_conn
from app.db.enums import HTTPStatus
from app.db.models import DbProject, DbUser
from app.integrations.integration_crud import (
generate_api_token,
)
from app.integrations.integration_deps import valid_api_token
from app.projects.project_deps import get_project

router = APIRouter(
prefix="/integrations",
tags=["integrations"],
responses={404: {"description": "Not found"}},
)


@router.get("/api-token")
async def get_api_token(
current_user: Annotated[DbUser, Depends(super_admin)],
db: Annotated[Connection, Depends(db_conn)],
):
"""Generate and return a new API token.
This can only be accessed once, and is regenerated on
each call to this endpoint.
Be sure to store it someplace safe, like a password manager.
NOTE currently requires super admin permission.
"""
try:
api_key = await generate_api_token(db, current_user.id)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=str(e),
) from e
return JSONResponse(
status_code=HTTPStatus.OK,
content={"api_key": api_key},
)


@router.post(
"/webhooks/entity-status",
response_model=EntityMappingStatus,
)
async def update_entity_status(
current_user: Annotated[DbUser, Depends(valid_api_token)],
project: Annotated[DbProject, Depends(get_project)],
entity_details: EntityMappingStatusIn,
):
"""Update the status for an Entity."""
return await update_entity_mapping_status(
project.odk_credentials,
project.odkid,
entity_details.entity_id,
entity_details.label,
entity_details.status,
)
2 changes: 2 additions & 0 deletions src/backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from app.db.database import db_conn, get_db_connection_pool
from app.db.enums import HTTPStatus
from app.helpers import helper_routes
from app.integrations import integration_routes
from app.monitoring import (
add_endpoint_profiler,
instrument_app_otel,
Expand Down Expand Up @@ -144,6 +145,7 @@ def get_application() -> FastAPI:
_app.include_router(auth_routes.router)
_app.include_router(submission_routes.router)
_app.include_router(organisation_routes.router)
_app.include_router(integration_routes.router)
_app.include_router(helper_routes.router)

return _app
Expand Down
1 change: 0 additions & 1 deletion src/backend/app/projects/project_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,6 @@ async def get_odk_entity_mapping_status(
async def set_odk_entities_mapping_status(
project_user: Annotated[ProjectUserDict, Depends(mapper)],
entity_details: central_schemas.EntityMappingStatusIn,
db: Annotated[Connection, Depends(db_conn)],
):
"""Set the ODK entities mapping status, i.e. in progress or complete.
Expand Down
11 changes: 11 additions & 0 deletions src/backend/migrations/005-api-key.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- ## Migration add an api_key field to the users table

-- Start a transaction

BEGIN;

ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS api_key CHARACTER VARYING;

-- Commit the transaction
COMMIT;
1 change: 1 addition & 0 deletions src/backend/migrations/init/fmtm_base_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ CREATE TABLE public.users (
tasks_validated integer NOT NULL DEFAULT 0,
tasks_invalidated integer NOT NULL DEFAULT 0,
projects_mapped integer [],
api_key character varying,
registered_at timestamp with time zone DEFAULT now()
);
ALTER TABLE public.users OWNER TO fmtm;
Expand Down

0 comments on commit 14559e1

Please sign in to comment.