Skip to content
This repository has been archived by the owner on Jul 25, 2024. It is now read-only.

Commit

Permalink
Merge pull request #115 from communitiesuk/feature/bau-improve-author…
Browse files Browse the repository at this point in the history
…isation

[BAU] improve authorisation
  • Loading branch information
cyrusdobbs authored Nov 30, 2023
2 parents 62cbc5d + 8ff779c commit 1928342
Show file tree
Hide file tree
Showing 5 changed files with 361 additions and 158 deletions.
11 changes: 7 additions & 4 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
from jinja2 import ChoiceLoader, PackageLoader, PrefixLoader

import static_assets
from app.const import EMAIL_DOMAIN_TO_LA_AND_PLACE_NAMES_AND_FUND_TYPES
from app.const import TOWNS_FUND_AUTH
from app.main.authorisation import AuthMapping, build_auth_mapping
from config import Config

assets = Environment()
Expand Down Expand Up @@ -43,9 +44,11 @@ def create_app(config_class=Config):
health = Healthcheck(app)
health.add_check(FlaskRunningChecker())

# instantiate email to LA and place and fund types mapping used for authorizing submissions
app.config["EMAIL_TO_LA_AND_PLACE_NAMES_AND_FUND_TYPES"] = copy(EMAIL_DOMAIN_TO_LA_AND_PLACE_NAMES_AND_FUND_TYPES)
app.config["EMAIL_TO_LA_AND_PLACE_NAMES_AND_FUND_TYPES"].update(app.config.get("ADDITIONAL_EMAIL_LOOKUPS", {}))
# TODO: TOWNS_FUND_AUTH is currently stored in const.py but this isn't isn't a good solution.
# We need to decide where we should store and inject specific auth mappings from.
email_mapping = copy(TOWNS_FUND_AUTH)
email_mapping.update(Config.ADDITIONAL_EMAIL_LOOKUPS)
app.config["AUTH_MAPPING"]: AuthMapping = build_auth_mapping(Config.FUND_NAME, email_mapping)

logging.init_app(app)
return app
Expand Down
87 changes: 62 additions & 25 deletions app/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ class MIMETYPE(StrEnum):
"HS": "Future High Streets Fund",
}

EMAIL_DOMAIN_TO_LA_AND_PLACE_NAMES_AND_FUND_TYPES = {
# domain/email: (LAs, Places, Funds)
TOWNS_FUND_AUTH = {
"ambervalley.gov.uk": (("Amber Valley Borough Council",), ("Heanor",), ("Town_Deal", "Future_High_Street_Fund")),
"ashfield.gov.uk": (
("Ashfield District Council",),
Expand Down Expand Up @@ -118,30 +119,66 @@ class MIMETYPE(StrEnum):
),
("Town_Deal", "Future_High_Street_Fund"),
),
"eastriding.gov.uk": (("East Riding of Yorkshire Council",), ("Goole",)),
"eaststaffsbc.gov.uk": (("East Staffordshire Borough Council",), ("Burton",)),
"erewash.gov.uk": (("Erewash Borough Council",), ("Long Eaton",)),
"fenland.gov.uk": (("Fenland District Council",), ("March High Street",)),
"fylde.gov.uk": (("Fylde Council",), ("Kirkham Town Centre",)),
"great-yarmouth.gov.uk": (("Great Yarmouth Borough Council",), ("Great Yarmouth",)),
"royalgreenwich.gov.uk": (("Royal Borough of Greenwich",), ("Woolwich Town Centre",)),
"halton.gov.uk": (("Halton Borough Council",), ("Runcorn",)),
"haringey.gov.uk": (("London Borough of Haringey",), ("Tottenham High Road",)),
"harlow.gov.uk": (("Harlow District Council",), ("Harlow",)),
"harrow.gov.uk": (("London Borough of Harrow",), ("Wealdstone",)),
"hartlepool.gov.uk": (("Hartlepool Borough Council",), ("Hartlepool",)),
"hastings.gov.uk": (("Hastings Borough Council",), ("Hastings",)),
"highpeak.gov.uk": (("High Peak Borough Council",), ("Buxton",)),
"huntingdonshire.gov.uk": (("Huntingdonshire District Council",), ("St Neots",)),
"ipswich.gov.uk": (("Ipswich Borough Council",), ("Ipswich",)),
"west-norfolk.gov.uk": (("King's Lynn and West Norfolk",), ("King's Lynn",)),
"kirklees.gov.uk": (("Kirklees Council",), ("Dewsbury")),
"leeds.gov.uk": (("Leeds Council",), ("Morley")),
"lewes-eastbourne.gov.uk": (("Lewes District Council",), ("Eastbourne Borough Council", "Newhaven")),
"ad.lewes-eastbourne.gov.uk": (("Lewes District Council",), ("Eastbourne Borough Council", "Newhaven")),
"lincoln.gov.uk": (("City of Lincoln Council",), ("Lincoln",)),
"mansfield.gov.uk": (("Mansfield District Council",), ("Mansfield",)),
"medway.gov.uk": (("Medway Council",), ("Chatham Town Centre",)),
"eastriding.gov.uk": (
("East Riding of Yorkshire Council",),
("Goole",),
("Town_Deal", "Future_High_Street_Fund"),
),
"eaststaffsbc.gov.uk": (
("East Staffordshire Borough Council",),
("Burton",),
("Town_Deal", "Future_High_Street_Fund"),
),
"erewash.gov.uk": (("Erewash Borough Council",), ("Long Eaton",), ("Town_Deal", "Future_High_Street_Fund")),
"fenland.gov.uk": (("Fenland District Council",), ("March High Street",), ("Town_Deal", "Future_High_Street_Fund")),
"fylde.gov.uk": (("Fylde Council",), ("Kirkham Town Centre",), ("Town_Deal", "Future_High_Street_Fund")),
"great-yarmouth.gov.uk": (
("Great Yarmouth Borough Council",),
("Great Yarmouth",),
("Town_Deal", "Future_High_Street_Fund"),
),
"royalgreenwich.gov.uk": (
("Royal Borough of Greenwich",),
("Woolwich Town Centre",),
("Town_Deal", "Future_High_Street_Fund"),
),
"halton.gov.uk": (("Halton Borough Council",), ("Runcorn",), ("Town_Deal", "Future_High_Street_Fund")),
"haringey.gov.uk": (
("London Borough of Haringey",),
("Tottenham High Road",),
("Town_Deal", "Future_High_Street_Fund"),
),
"harlow.gov.uk": (("Harlow District Council",), ("Harlow",), ("Town_Deal", "Future_High_Street_Fund")),
"harrow.gov.uk": (("London Borough of Harrow",), ("Wealdstone",), ("Town_Deal", "Future_High_Street_Fund")),
"hartlepool.gov.uk": (("Hartlepool Borough Council",), ("Hartlepool",), ("Town_Deal", "Future_High_Street_Fund")),
"hastings.gov.uk": (("Hastings Borough Council",), ("Hastings",), ("Town_Deal", "Future_High_Street_Fund")),
"highpeak.gov.uk": (("High Peak Borough Council",), ("Buxton",), ("Town_Deal", "Future_High_Street_Fund")),
"huntingdonshire.gov.uk": (
("Huntingdonshire District Council",),
("St Neots",),
("Town_Deal", "Future_High_Street_Fund"),
),
"ipswich.gov.uk": (("Ipswich Borough Council",), ("Ipswich",), ("Town_Deal", "Future_High_Street_Fund")),
"west-norfolk.gov.uk": (
("King's Lynn and West Norfolk",),
("King's Lynn",),
("Town_Deal", "Future_High_Street_Fund"),
),
"kirklees.gov.uk": (("Kirklees Council",), ("Dewsbury",), ("Town_Deal", "Future_High_Street_Fund")),
"leeds.gov.uk": (("Leeds Council",), ("Morley",), ("Town_Deal", "Future_High_Street_Fund")),
"lewes-eastbourne.gov.uk": (
("Lewes District Council",),
("Eastbourne Borough Council", "Newhaven"),
("Town_Deal", "Future_High_Street_Fund"),
),
"ad.lewes-eastbourne.gov.uk": (
("Lewes District Council",),
("Eastbourne Borough Council", "Newhaven"),
("Town_Deal", "Future_High_Street_Fund"),
),
"lincoln.gov.uk": (("City of Lincoln Council",), ("Lincoln",), ("Town_Deal", "Future_High_Street_Fund")),
"mansfield.gov.uk": (("Mansfield District Council",), ("Mansfield",), ("Town_Deal", "Future_High_Street_Fund")),
"medway.gov.uk": (("Medway Council",), ("Chatham Town Centre",), ("Town_Deal", "Future_High_Street_Fund")),
"mendip.gov.uk": (
("Somerset Council",),
(
Expand Down
160 changes: 111 additions & 49 deletions app/main/authorisation.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,119 @@
from flask import abort, current_app, g
from abc import ABC, abstractmethod


def check_authorised() -> tuple[tuple[str], dict[str]]:
"""Checks that the user is authorized to submit.
def validate_auth_args(func):
"""Validates that all args passed to the decorated function are tuples of strings.
Returns any LAs, places, and fund types that the user is authorized to submit for, otherwise aborts and redirects
to 401 (unauthorised) page.
:return: the LAs as a tuple, and a dictionary with both the place_names and fund_types
:param func: the decorated function
:raises ValueError: if the args are invalid
"""
local_authorities, place_names, fund_types = get_local_authority_and_place_names_and_fund_types(g.user.email)
if local_authorities is None or place_names is None or fund_types is None:
current_app.logger.error(
f"User: {g.user.email} has not been assigned any local authorities and/or places and/or fund types"
)
abort(401) # unauthorized
current_app.logger.info(
f"User: {g.user.email} from {', '.join(local_authorities)} is authorised for places: {', '.join(place_names)}"
f"and fund types: {', '.join(fund_types)}"
)
return local_authorities, {"Place Names": place_names, "Fund Types": fund_types}


def get_local_authority_and_place_names_and_fund_types(
user_email: str,
) -> tuple[tuple[str] | None, tuple[str] | None, tuple[str] | None]:

def wrapper(*args):
for arg in args:
if isinstance(arg, AuthBase):
continue # don't validate self
if not isinstance(arg, tuple):
raise ValueError(f"Expected a tuple, but got {type(arg).__name__} in args: {args}")
if not all(isinstance(item, str) for item in arg):
raise ValueError(f"All elements in the tuple must be strings in args: {args}")
return func(*args)

return wrapper


class AuthBase(ABC):
"""Auth class ABC. Classes that inherit must implement a constructor, organisations and auth_dict methods."""

@abstractmethod
def __init__(self, *args):
pass

@abstractmethod
def get_organisations(self) -> tuple[str, ...]:
"""Return organisations associated with this level of authorisation."""
pass

@abstractmethod
def get_auth_dict(self) -> dict:
"""Return other details associated with this authorisation."""
pass


class TFAuth(AuthBase):
"""A Towns Fund Auth Class"""

local_authorities: tuple[str, ...]
place_names: tuple[str, ...]
fund_types: tuple[str, ...]

@validate_auth_args
def __init__(self, local_authorities: tuple[str, ...], place_names: tuple[str, ...], fund_types: tuple[str, ...]):
self.local_authorities = local_authorities
self.place_names = place_names
self.fund_types = fund_types

def get_organisations(self) -> tuple[str, ...]:
return self.local_authorities

def get_auth_dict(self) -> dict:
return {"Place Names": self.place_names, "Fund Types": self.fund_types}


class AuthMapping:
"""Encapsulates an email mapping dictionary. Allows lookup of an email address."""

_auth_class: type[AuthBase]
_mapping: dict[str, AuthBase]

def __init__(self, auth_class: type[AuthBase], mapping: dict[str, tuple[tuple[str, ...], ...]]):
"""Instantiates an AuthMapping from an Auth class and a set of dictionary mappings.
:param auth_class: the Auth class implementation that this AuthMapping will store
:param mapping: a dictionary mapping emails to a set of auth details that are held within Auth objects
"""
self._auth_class = auth_class
# for each item in the dictionary, encapsulate the auth details values in an instance of the auth_class
self._mapping = {email: auth_class(*auth_details) for email, auth_details in mapping.items()}

def get_auth(self, email: str) -> AuthBase | None:
"""Get the authorisation information associated with the given email address.
This lookup is case-insensitive.
Lookup hierarchy:
1. Full Email
2. Email Domain
:param email: email address
:return: the associated Auth
"""
domain = email.split("@")[1]
# first match on full email, then try domain
auth = self._mapping.get(email.lower()) or self._mapping.get(domain.lower())
return auth


def _auth_class_factory(fund: str) -> type[AuthBase]:
"""Given a fund, returns the associated auth class.
:param fund: Fund Name
:return: associated Auth class
:raises ValueError:
"""
Get the local authority, place names, and fund types corresponding to a user's email.
match fund:
case "Towns Fund":
return TFAuth
case _:
raise ValueError("Unknown Fund")


This function takes a user's email address and uses the domain part (after '@')
to look up the corresponding place names and fund types the user can submit returns for.
If the domain is not present in the look-up, the user may be a private contractor
who cannot be verified by the domain alone, and so a look-up of the entire
e-mail address is performed. Where this is not found, a tuple containing None
will be returned.
def build_auth_mapping(fund_name: str, mapping: dict[str, tuple[tuple[str, ...], ...]]) -> AuthMapping:
"""Given a fund and a set of email mappings, return an auth mapping object.
:param user_email: A string representing the user's email address.
:return: A tuple of local authorities, place names, and fund types under their remit.
:param fund_name: the fund associated with this mapping
:param mapping: a mapping of email/domains -> (organisation, *other_auth_details)
:return: an AuthMapping
"""
email_mapping = current_app.config["EMAIL_TO_LA_AND_PLACE_NAMES_AND_FUND_TYPES"]
email_domain = user_email.split("@")[1]
# if the domain is not present in the lookup, we will check with the whole e-mail
la_and_place_names_and_fund_types = email_mapping.get(email_domain.lower()) or email_mapping.get(
user_email.lower(), (None, None, None)
)

# TODO: remove this once successfully deployed with updated secret
if len(la_and_place_names_and_fund_types) == 3:
return la_and_place_names_and_fund_types
else:
current_app.logger.warning("Secret auth mapping is invalid - adding TD and FHSF and continuing")
return (
la_and_place_names_and_fund_types[0],
la_and_place_names_and_fund_types[1],
("Town_Deal", "Future_High_Street_Fund"),
)
auth_class: type[AuthBase] = _auth_class_factory(fund_name)
auth_mapping = AuthMapping(auth_class, mapping)
return auth_mapping
32 changes: 28 additions & 4 deletions app/main/routes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json

from flask import current_app, g, redirect, render_template, request, url_for
from flask import abort, current_app, g, redirect, render_template, request, url_for
from fsd_utils.authentication.config import SupportedApp
from fsd_utils.authentication.decorators import login_requested, login_required
from werkzeug.datastructures import FileStorage
Expand All @@ -14,7 +14,7 @@
VALIDATION_LOG,
)
from app.main import bp
from app.main.authorisation import check_authorised
from app.main.authorisation import AuthBase, AuthMapping
from app.main.data_requests import post_ingest
from app.main.notify import send_confirmation_emails
from app.utils import calculate_days_to_deadline, is_load_enabled
Expand All @@ -38,7 +38,7 @@ def login():
@bp.route("/upload", methods=["GET", "POST"])
@login_required(return_app=SupportedApp.POST_AWARD_SUBMIT, roles_required=[Config.TF_SUBMITTER_ROLE])
def upload():
local_authorities, auth = check_authorised()
local_authorities, auth_dict = check_authorised()

if request.method == "GET":
return render_template(
Expand Down Expand Up @@ -67,7 +67,7 @@ def upload():

success, pre_errors, validation_errors, metadata = post_ingest(
excel_file,
{"reporting_round": 4, "auth": json.dumps(auth), "do_load": is_load_enabled()},
{"reporting_round": 4, "auth": json.dumps(auth_dict), "do_load": is_load_enabled()},
)

if success:
Expand Down Expand Up @@ -104,6 +104,30 @@ def upload():
)


def check_authorised() -> tuple[tuple[str, ...], dict[str, tuple[str, ...]]]:
"""Checks that the user is authorized to submit.
Returns any Organisations that the user is authorized to submit for, along with any authorisation details to check
against the submission.
Otherwise, if they are not authorised for any submissions, aborts and redirects to 401 (unauthorised) page.
:return: the organisation as a tuple, and a dictionary of authorisation details to check against the submission
"""
auth_mapping: AuthMapping = current_app.config["AUTH_MAPPING"]
auth: AuthBase = auth_mapping.get_auth(g.user.email)

if auth is None:
current_app.logger.error(f"User: {g.user.email} has not been assigned any authorisation")
abort(401) # unauthorized

current_app.logger.info(
f"User: {g.user.email} from {', '.join(auth.get_organisations())} is authorised for: {auth.get_auth_dict()}"
)

return auth.get_organisations(), auth.get_auth_dict()


def check_file(excel_file: FileStorage) -> str | None:
"""Basic checks on an uploaded file.
Expand Down
Loading

0 comments on commit 1928342

Please sign in to comment.