Skip to content

Commit

Permalink
Merge pull request #40 from bento-platform/refact/use-bento-lib-boile…
Browse files Browse the repository at this point in the history
…rplate

refact: use bento_lib boilerplate for some authz + app setup
  • Loading branch information
davidlougheed authored May 24, 2024
2 parents 604d00c + e414a62 commit c23babc
Show file tree
Hide file tree
Showing 13 changed files with 177 additions and 227 deletions.
98 changes: 98 additions & 0 deletions bento_authorization_service/authz.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import jwt

from bento_lib.auth.middleware.fastapi import FastApiAuthMiddleware
from bento_lib.auth.permissions import Permission
from fastapi import Depends, HTTPException, Request, status

from .config import get_config
from .db import Database, DatabaseDependency
from .dependencies import OptionalBearerToken
from .idp_manager import IdPManager, IdPManagerDependency
from .logger import logger
from .models import ResourceModel
from .policy_engine.evaluation import evaluate
from .utils import extract_token


# TODO: Find a way to DI this
config_for_setup = get_config()


class LocalFastApiAuthMiddleware(FastApiAuthMiddleware):
def forbidden(self, request: Request) -> HTTPException:
self.mark_authz_done(request)
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")

# Set up our own methods for doing authorization instead of using the middleware default ones, since they make HTTP
# calls to this service, which we should skip and replace with evaluate() calls.

async def raise_if_no_resource_access(
self,
request: Request,
token: str,
resource: ResourceModel,
required_permission: Permission,
db: Database,
idp_manager: IdPManager,
) -> None:
try:
eval_res = (await evaluate(idp_manager, db, token, (resource,), (required_permission,)))[0][0]
if not eval_res:
# Forbidden from accessing or deleting this grant
raise self.forbidden(request)
except HTTPException as e:
raise e # Pass it on
except jwt.ExpiredSignatureError: # Straightforward, expired token - don't bother logging
raise self.forbidden(request)
except Exception as e: # Could not properly run evaluate(); return forbidden!
logger.error(
f"Encountered error while checking permissions for request {request.method} {request.url.path}: "
f"{repr(e)}"
)
raise self.forbidden(request)

async def require_permission_and_flag(
self,
resource: ResourceModel,
permission: Permission,
request: Request,
authorization: OptionalBearerToken,
db: DatabaseDependency,
idp_manager: IdPManagerDependency,
):
await self.raise_if_no_resource_access(
request,
extract_token(authorization),
resource,
permission,
db,
idp_manager,
)
# Flag that we have thought about auth
authz_middleware.mark_authz_done(request)

def require_permission_dependency(self, resource: ResourceModel, permission: Permission):
async def _inner(
request: Request,
authorization: OptionalBearerToken,
db: DatabaseDependency,
idp_manager: IdPManagerDependency,
):
return await self.require_permission_and_flag(
resource,
permission,
request,
authorization,
db,
idp_manager,
)

return Depends(_inner)


authz_middleware = LocalFastApiAuthMiddleware(
config_for_setup.bento_authz_service_url,
debug_mode=config_for_setup.bento_debug,
logger=logger,
enabled=config_for_setup.bento_authz_enabled,
)
5 changes: 2 additions & 3 deletions bento_authorization_service/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from bento_lib.config.pydantic import BentoBaseConfig
from bento_lib.config.pydantic import BentoFastAPIBaseConfig
from fastapi import Depends
from functools import lru_cache
from typing import Annotated
Expand All @@ -12,14 +12,13 @@
]


class Config(BentoBaseConfig):
class Config(BentoFastAPIBaseConfig):
# the superclass has this as a required field - we ignore it since this IS the authz service
# TODO: should this maybe be a [str | None] in bento_lib?
bento_authz_service_url: str = ""

service_id: str = f"{SERVICE_GROUP}:{SERVICE_ARTIFACT}"
service_name: str = "Bento Authorization Service"
service_url_base_path: str = "http://127.0.0.1:5000" # Base path to construct URIs from

database_uri: str = "postgres://localhost:5432"

Expand Down
86 changes: 11 additions & 75 deletions bento_authorization_service/main.py
Original file line number Diff line number Diff line change
@@ -1,96 +1,32 @@
from bento_lib.responses.fastapi_errors import http_exception_handler_factory, validation_exception_handler_factory
from bento_lib.service_info.helpers import build_service_info_from_pydantic_config
from bento_lib.apps.fastapi import BentoFastAPI
from bento_lib.service_info.types import BentoExtraServiceInfo
from fastapi import FastAPI, Request, Response, status
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from urllib.parse import urlparse

from . import __version__
from .config import ConfigDependency, get_config
from .authz import authz_middleware
from .config import get_config
from .constants import BENTO_SERVICE_KIND, SERVICE_TYPE
from .logger import logger
from .routers.all_permissions import all_permissions_router
from .routers.grants import grants_router
from .routers.groups import groups_router
from .routers.policy import policy_router
from .routers.schemas import schema_router
from .routers.utils import MarkAuthzDone, public_endpoint_dependency


# TODO: Find a way to DI this
config_for_setup = get_config()
BENTO_SERVICE_INFO: BentoExtraServiceInfo = {
"serviceKind": BENTO_SERVICE_KIND,
"dataService": False,
"gitRepository": "https://github.com/bento-platform/bento_authorization_service",
}

DOCS_URL = "/docs"
OPENAPI_URL = "/openapi.json"

app = FastAPI(
title=config_for_setup.service_name,
root_path=urlparse(config_for_setup.service_url_base_path).path,
docs_url=DOCS_URL,
openapi_url=OPENAPI_URL,
version=__version__,
)
app.add_middleware(
CORSMiddleware,
allow_origins=config_for_setup.cors_origins,
allow_headers=["Authorization"],
allow_credentials=True,
allow_methods=["*"],
)
# TODO: Find a way to DI this
config_for_setup = get_config()

app.exception_handler(StarletteHTTPException)(http_exception_handler_factory(logger, MarkAuthzDone))
app.exception_handler(RequestValidationError)(validation_exception_handler_factory(MarkAuthzDone))
app = BentoFastAPI(authz_middleware, config_for_setup, logger, BENTO_SERVICE_INFO, SERVICE_TYPE, __version__)

app.include_router(all_permissions_router)
app.include_router(grants_router)
app.include_router(groups_router)
app.include_router(policy_router)
app.include_router(schema_router)


@app.middleware("http")
async def permissions_enforcement(request: Request, call_next) -> Response:
"""
Permissions enforcement middleware. We require all endpoints to explicitly set a flag to say they have 'thought'
about permissions and decided the request should go through (or be rejected).
"""

# Allow pre-flight responses through
# Allow docs responses through in development mode
req_path = request.url.path
if request.method == "OPTIONS" or (
config_for_setup.bento_debug
and (req_path == DOCS_URL or req_path.startswith(f"{DOCS_URL}/") or req_path == OPENAPI_URL)
):
return await call_next(request)

# Set flag saying the request hasn't had its permissions determined yet.
request.state.determined_authz = False

# Run the next steps in the response chain
response: Response = await call_next(request)

if not request.state.determined_authz:
# Next in response chain didn't properly think about auth; return 403
logger.warning(
f"Masking true response with 403 since determined_authz was not set: {request.url} {response.status_code}"
)
return JSONResponse(status_code=status.HTTP_403_FORBIDDEN, content={"detail": "Forbidden"})

# Otherwise, return the response as normal
return response


BENTO_SERVICE_INFO: BentoExtraServiceInfo = {
"serviceKind": BENTO_SERVICE_KIND,
"dataService": False,
"gitRepository": "https://github.com/bento-platform/bento_authorization_service",
}


@app.get("/service-info", dependencies=[public_endpoint_dependency])
async def service_info(config: ConfigDependency):
return await build_service_info_from_pydantic_config(config, logger, BENTO_SERVICE_INFO, SERVICE_TYPE, __version__)
4 changes: 2 additions & 2 deletions bento_authorization_service/routers/all_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from fastapi import APIRouter
from pydantic import BaseModel

from .utils import public_endpoint_dependency
from ..authz import authz_middleware

__all__ = [
"all_permissions_router",
Expand Down Expand Up @@ -31,6 +31,6 @@ def response_item_from_permission(p: Permission) -> PermissionResponseItem:
)


@all_permissions_router.get("/", dependencies=[public_endpoint_dependency])
@all_permissions_router.get("/", dependencies=[authz_middleware.dep_public_endpoint()])
def list_all_permissions() -> list[PermissionResponseItem]:
return list(map(response_item_from_permission, PERMISSIONS))
19 changes: 11 additions & 8 deletions bento_authorization_service/routers/grants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request, status

from ..authz import authz_middleware
from ..db import Database, DatabaseDependency
from ..dependencies import OptionalBearerToken
from ..idp_manager import IdPManager, IdPManagerDependency
from ..models import GrantModel, StoredGrantModel
from ..policy_engine.evaluation import evaluate
from .utils import raise_if_no_resource_access, extract_token, public_endpoint_dependency, MarkAuthzDone
from ..utils import extract_token

__all__ = [
"grants_router",
Expand All @@ -34,16 +35,18 @@ async def get_grant_and_check_access(
idp_manager: IdPManager,
) -> StoredGrantModel:
if (grant := await db.get_grant(grant_id)) is not None:
await raise_if_no_resource_access(request, token, grant.resource, required_permission, db, idp_manager)
await authz_middleware.raise_if_no_resource_access(
request, token, grant.resource, required_permission, db, idp_manager
)
return grant

# Flag that we have thought about auth - since we are about to raise a NotFound error; consider this OK since
# any user could theoretically see some grants.
MarkAuthzDone.mark_authz_done(request)
authz_middleware.mark_authz_done(request)
raise grant_not_found(grant_id)


@grants_router.get("/", dependencies=[public_endpoint_dependency])
@grants_router.get("/", dependencies=[authz_middleware.dep_public_endpoint()])
async def list_grants(
db: DatabaseDependency,
idp_manager: IdPManagerDependency,
Expand All @@ -69,12 +72,12 @@ async def create_grant(
) -> StoredGrantModel:
# Make sure the token is allowed to edit permissions (in this case, 'editing permissions'
# extends to creating grants) on the resource in question.
await raise_if_no_resource_access(
await authz_middleware.raise_if_no_resource_access(
request, extract_token(authorization), grant.resource, P_EDIT_PERMISSIONS, db, idp_manager
)

# Flag that we have thought about auth
MarkAuthzDone.mark_authz_done(request)
authz_middleware.mark_authz_done(request)

# Forbid creating a grant which is expired from the get-go.
if grant.expiry is not None and grant.expiry < datetime.now(timezone.utc):
Expand Down Expand Up @@ -116,7 +119,7 @@ async def get_grant(
)

# Flag that we have thought about auth
MarkAuthzDone.mark_authz_done(request)
authz_middleware.mark_authz_done(request)

return grant

Expand All @@ -135,7 +138,7 @@ async def delete_grant(
)

# Flag that we have thought about auth
MarkAuthzDone.mark_authz_done(request)
authz_middleware.mark_authz_done(request)

# If the above didn't raise anything, delete the grant.
await db.delete_grant(grant_id)
Expand Down
17 changes: 11 additions & 6 deletions bento_authorization_service/routers/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

from fastapi import APIRouter, HTTPException, status

from ..authz import authz_middleware
from ..db import DatabaseDependency
from ..models import RESOURCE_EVERYTHING, GroupModel, StoredGroupModel
from .utils import require_permission_dependency

__all__ = [
"groups_router",
Expand All @@ -22,15 +22,17 @@ def group_not_created() -> HTTPException:
return HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Group could not be created")


@groups_router.get("/", dependencies=[require_permission_dependency(RESOURCE_EVERYTHING, P_VIEW_PERMISSIONS)])
@groups_router.get(
"/", dependencies=[authz_middleware.require_permission_dependency(RESOURCE_EVERYTHING, P_VIEW_PERMISSIONS)]
)
async def list_groups(db: DatabaseDependency) -> list[StoredGroupModel]:
return await db.get_groups()


@groups_router.post(
"/",
status_code=status.HTTP_201_CREATED,
dependencies=[require_permission_dependency(RESOURCE_EVERYTHING, P_EDIT_PERMISSIONS)],
dependencies=[authz_middleware.require_permission_dependency(RESOURCE_EVERYTHING, P_EDIT_PERMISSIONS)],
)
async def create_group(
group: GroupModel,
Expand All @@ -47,7 +49,10 @@ async def create_group(
raise group_not_created()


@groups_router.get("/{group_id}", dependencies=[require_permission_dependency(RESOURCE_EVERYTHING, P_VIEW_PERMISSIONS)])
@groups_router.get(
"/{group_id}",
dependencies=[authz_middleware.require_permission_dependency(RESOURCE_EVERYTHING, P_VIEW_PERMISSIONS)],
)
async def get_group(group_id: int, db: DatabaseDependency) -> StoredGroupModel:
# TODO: sub-groups owned by another group
# TODO: test permissions for this endpoint
Expand All @@ -60,7 +65,7 @@ async def get_group(group_id: int, db: DatabaseDependency) -> StoredGroupModel:
@groups_router.delete(
"/{group_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[require_permission_dependency(RESOURCE_EVERYTHING, P_EDIT_PERMISSIONS)],
dependencies=[authz_middleware.require_permission_dependency(RESOURCE_EVERYTHING, P_EDIT_PERMISSIONS)],
)
async def delete_group(group_id: int, db: DatabaseDependency) -> None:
# TODO: sub-groups owned by another group
Expand All @@ -74,7 +79,7 @@ async def delete_group(group_id: int, db: DatabaseDependency) -> None:
@groups_router.put(
"/{group_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[require_permission_dependency(RESOURCE_EVERYTHING, P_EDIT_PERMISSIONS)],
dependencies=[authz_middleware.require_permission_dependency(RESOURCE_EVERYTHING, P_EDIT_PERMISSIONS)],
)
async def update_group(group_id: int, group: GroupModel, db: DatabaseDependency) -> None:
# TODO: sub-groups owned by another group
Expand Down
Loading

0 comments on commit c23babc

Please sign in to comment.