Skip to content

Commit

Permalink
move auth to sentinel module, share engine with model.py
Browse files Browse the repository at this point in the history
  • Loading branch information
stamler committed Jun 29, 2018
1 parent 224849c commit 5f467bb
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 22 deletions.
12 changes: 6 additions & 6 deletions charade/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from sqlalchemy.orm import Session
from sqlalchemy.exc import DatabaseError
from typing import Any, Dict
from .sentinel import bind_engine as sentinel_bind_engine

class Database(object):

Expand All @@ -32,12 +33,8 @@ def __init__(self, config: Dict[str, Any]) -> None:

engine = create_engine(config['db'], pool_pre_ping=True)
try:

# TODO: load charade_middleware_authorization config and add
# configure Base, then load model.py. If model.py isn't
# found, then automap the rest

from .model import Base
from .model import Base, bind_engine
bind_engine(engine)
self.Base = Base
self.log.debug("Found and loaded model.py")
except ModuleNotFoundError as e:
Expand All @@ -49,6 +46,9 @@ def __init__(self, config: Dict[str, Any]) -> None:
self.Base = Base
self.log.debug("model.py not found, running with automap")

# Bind the authorization module to our existing database engine
sentinel_bind_engine(engine)

try:
# TODO: There's an issue in these two try/except blocks.
# the DatabaseError needs to be caught precisely after the first
Expand Down
18 changes: 2 additions & 16 deletions charade/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from cryptography.x509 import load_pem_x509_certificate as load_cert
from cryptography.hazmat.backends import default_backend
from urllib.parse import urlsplit

from .sentinel import authorize as authorize_request

# The Authentication and Authorization section of the app.
# Load keys from Microsoft and cache them for refresh_interval before reloading
Expand Down Expand Up @@ -141,21 +141,7 @@ def authorize(self, claims, req):
except IndexError:
res = '/'

# Get the list of groups allowed to use this method on
# this resource and store it in authorized_groups[]
Requests = db_obj.resources['Requests']['sqla_obj']
Roles = db_obj.resources['Roles']['sqla_obj']
Permissions = db_obj.resources['Permissions']['sqla_obj']
requests_roles = db_obj.Base.metadata.tables['_charade_mddlwr_athrztn_requests_roles']
query = db_obj.get_session().query(Permissions.group_oid, Roles.name,
Requests.verb, Requests.resource).\
join(Roles).\
join(requests_roles).\
join(Requests).\
filter(Permissions.group_oid.in_(security_groups)).\
filter(Requests.verb == req.method).\
filter(Requests.resource == res)
row = query.first()
row = authorize_request(security_groups, req.method, res)

if row == None:
self.log.debug("Denied: {} {}".format(req.method, res))
Expand Down
161 changes: 161 additions & 0 deletions charade/sentinel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# sentinel, the authorization mechanism and used by the middleware

from sqlalchemy import create_engine, Table, Column, Integer, ForeignKey, Index, String
from sqlalchemy.orm import relationship, Session
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql.expression import insert, literal_column
from typing import Any, List, Tuple
import logging

log = logging.getLogger()
log.setLevel(logging.DEBUG)
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
log.addHandler(ch)

# This declaration is annotated with a comment for
# mypy because of https://github.com/python/mypy/issues/2477
Base = declarative_base() # type: Any
session: Session

# Use the provided engine to interact with the database
# https://docs.python.org/3.6/tutorial/classes.html#python-scopes-and-namespaces
def bind_engine(engine):
global log
log.debug("bind_engine() called on sentinel")
Base.metadata.bind = engine
global session
session = Session(engine)

def authorize(groups: List[str], method: str, resource: str) -> Tuple:
global log
log.debug("authorize() called on sentinel")
query = session.query(Permissions.group_oid, Roles.name,
Requests.verb, Requests.resource).\
join(Roles).\
join(requests_roles).\
join(Requests).\
filter(Permissions.group_oid.in_(groups)).\
filter(Requests.verb == method).\
filter(Requests.resource == resource)
return query.first()


# The association table for the many-to-many relationship
# between Requests and Roles. No primary key
requests_roles = Table('_sentinel_requests_roles', Base.metadata,
Column('requests_id', Integer, ForeignKey('_sentinel_requests.id')),
Column('roles_id', Integer, ForeignKey('_sentinel_roles.id'))
)

# Requests are endpoints (resource strings not including baseURL)
# with corresponding verbs (HTTP methods). They are keyed to an id. When an
# authenticated request is made sentinel looks up the roles containing the
# request. It then queries permissions for groups that have this role. The
# request is allowed only if at least one of the groups in the token is
# assigned a corresponding role.
class Requests(Base):
__tablename__ = '_sentinel_requests'
__table_args__ = (
Index('request_set', 'verb', 'resource', unique=True),
)

id = Column(Integer, primary_key=True)
verb = Column(String(10), info={'title':'Verb','placeholder':'HTTP method'}, nullable=False)
resource = Column(String(24), info={'title':'Resource','placeholder': 'Resource Name with preceeding /'}, nullable=False)

roles = relationship("Roles", secondary=requests_roles,
back_populates="requests")

# Roles bundle together multiple requests so they can have permissions
# assigned to a user or group as a set. They are just a name and an id.
class Roles(Base):
__tablename__ = '_sentinel_roles'

id = Column(Integer, primary_key=True)
name = Column(String(32), info={'title':'Name','placeholder':'Descriptive Role Name'}, nullable=False)

requests = relationship("Requests", secondary=requests_roles,
back_populates="roles")

# Permissions are mapped sets of Roles and Group OIDs. Group OIDs are managed
# by AzureAD and provided to charade in the request's bearer token.
class Permissions(Base):
__tablename__ = '_sentinel_permissions'
__table_args__ = (
Index('permission_set', 'group_oid', 'roles_id', unique=True),
)

id = Column(Integer, primary_key=True)
group_oid = Column(String(40), info={'title':'OID','placeholder':'Group OID from Azure AD'}, nullable=False)
roles_id = Column(ForeignKey('_sentinel_roles.id'), nullable=False, index=True)

roles = relationship('Roles')

# Determining authorization
# When a request is received, it is authorized by determining whether there
# exists a relationship in the database where both the request and provided
# group_oid can be linked. A query where one or more rows are returned for
# given group_oid and request will indicate authorization. In the following
# code if 'row' is none, then the request is unauthorized
#
# query = session.query(Permissions.group_oid, Roles.name,
# Requests.verb, Requests.resource).\
# join(Roles).\
# join(requests_roles).\
# join(Requests).\
# filter(Permissions.group_oid.in_(security_groups)).\
# filter(Requests.verb == req.method).\
# filter(Requests.resource == res)
# row = query.first()

# Populate Requests Table. Run only once at db creation
# to set up all of the API requests for every class.
# Resource URLs are given ids on tens, tens+0 being GET
# Aborts on non-empty table
def init_sentinel_tables(session):
try:
# TODO: Drop all of the tables if flag is set then re-create them
# TODO: test that all tables *don't exist* rather than one is empty
assert(session.query(Requests).first() is None)

session.add(Requests(id=1,verb='GET',resource='/'))

# First populate requests for every endpoint
tens = 10
for subclass in Base.__subclasses__():
resource = '/' + subclass.__name__
session.add(Requests(id=tens,verb='GET',resource=resource))
session.add(Requests(id=tens+1,verb='POST',resource=resource))
session.add(Requests(id=tens+2,verb='PATCH',resource=resource))
session.add(Requests(id=tens+3,verb='DELETE',resource=resource))
session.commit()
tens += 10

# Then populate basic standard roles
session.add(Roles(id=1,name='UNRESTRICTED_ALL'))
session.add(Roles(id=1000,name='READ_ALL'))
session.commit()

# Then populate requests_roles (the join table)
# INSERT INTO charade_requests_roles (requests_id, roles_id)
# SELECT id, 1 FROM charade_requests WHERE verb='GET';
# http://docs.sqlalchemy.org/en/latest/changelog/migration_09.html#insert-from-select
# http://docs.sqlalchemy.org/en/latest/core/dml.html#sqlalchemy.sql.expression.Insert.from_select
s = session.query(Requests.id, literal_column("1000")).\
filter(Requests.verb == 'GET')
ins = insert(requests_roles).\
from_select(['requests_id', 'roles_id'], s)
session.execute(ins)

# INSERT INTO charade_requests_roles (requests_id, roles_id)
# SELECT id, 1000 FROM charade_requests;
s = session.query(Requests.id, literal_column("1"))
ins = insert(requests_roles).from_select(['requests_id', 'roles_id'], s)
session.execute(ins)
session.commit()

except AssertionError:
global log
log.debug("A table is not empty. No changes made.")

0 comments on commit 5f467bb

Please sign in to comment.