Skip to content

Commit

Permalink
Implement a conditional request plugin interface so access requests c…
Browse files Browse the repository at this point in the history
…an be automatically approved or denied (#34)
  • Loading branch information
somethingnew2-0 authored Apr 17, 2024
1 parent 1fde1aa commit 4275a7f
Show file tree
Hide file tree
Showing 17 changed files with 335 additions and 64 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,11 @@ Access uses the [Python pluggy framework](https://pluggy.readthedocs.io/en/lates

### Creating a Plugin

An example implementation of a notification plugin is included in [examples/plugins/notifications](https://github.com/discord/access/tree/main/examples/plugins/notifications) which can be extended to send messages using custom Python code. It implements the `NotificationPluginSpec` found in [notifications.py](https://github.com/discord/access/blob/main/api/plugins/notifications.py) following the conventions defined by the [Python pluggy framework](https://pluggy.readthedocs.io/en/latest/).
Plugins in Access follow the conventions defined by the [Python pluggy framework](https://pluggy.readthedocs.io/en/latest/).

An example implementation of a notification plugin is included in [examples/plugins/notifications](https://github.com/discord/access/tree/main/examples/plugins/notifications) which can be extended to send messages using custom Python code. It implements the `NotificationPluginSpec` found in [notifications.py](https://github.com/discord/access/blob/main/api/plugins/notifications.py)

There's also an example implementation of an conditional access plugin in [examples/plugins/conditional_access](https://github.com/discord/access/tree/main/examples/plugins/conditional_access) which can be extended to conditionally approve or deny requests. It implements the `ConditionalAccessPluginSpec` found in [requests.py](https://github.com/discord/access/blob/main/api/plugins/conditional_access.py)

### Installing a Plugin in the Docker Container

Expand Down
33 changes: 22 additions & 11 deletions api/operations/approve_access_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,34 @@ def __init__(
self,
*,
access_request: AccessRequest | str,
approver_user: OktaUser | str,
approver_user: Optional[OktaUser | str] = None,
approval_reason: str = "",
ending_at: Optional[datetime] = None,
notify: bool = True,
):
self.access_request = AccessRequest.query.options(
joinedload(AccessRequest.active_requested_group)
).filter(
AccessRequest.id == (access_request if isinstance(access_request, str) else access_request.id)
).first()

if isinstance(approver_user, str):
self.approver = db.session.get(OktaUser, approver_user)
if approver_user is None:
self.approver_id = None
self.approver_email = None
elif isinstance(approver_user, str):
approver = db.session.get(OktaUser, approver_user)
self.approver_id = approver.id
self.approver_email = approver.email
else:
self.approver = approver_user
self.approver_id = approver_user.id
self.approver_email = approver_user.email

self.approval_reason = approval_reason

self.ending_at = ending_at

self.notify = notify

self.notification_hook = get_notification_hook()

def execute(self) -> AccessRequest:
Expand All @@ -47,7 +56,7 @@ def execute(self) -> AccessRequest:
return self.access_request

# Don't allow requester to approve their own request
if self.access_request.requester_user_id == self.approver.id:
if self.access_request.requester_user_id == self.approver_id:
return self.access_request

# Don't allow approving a request if the reason is invalid and required
Expand All @@ -74,7 +83,7 @@ def execute(self) -> AccessRequest:
# Now handled inside ModifyGroupUsers
# self.access_request.status = AccessRequestStatus.APPROVED
# self.access_request.resolved_at = db.func.now()
# self.access_request.resolver_user_id = self.approver.id
# self.access_request.resolver_user_id = self.approver_id
# self.access_request.resolution_reason = self.approval_reason
# self.access_request.approval_ending_at = self.ending_at

Expand All @@ -93,8 +102,8 @@ def execute(self) -> AccessRequest:
'user_agent' : request.headers.get('User-Agent') if context else None,
'ip' : request.headers.get('X-Forwarded-For', request.headers.get('X-Real-IP', request.remote_addr))
if context else None,
'current_user_id' : self.approver.id,
'current_user_email' : self.approver.email,
'current_user_id' : self.approver_id,
'current_user_email' : self.approver_email,
'group' : group,
'request' : self.access_request,
'requester' : db.session.get(OktaUser, self.access_request.requester_user_id)
Expand All @@ -103,18 +112,20 @@ def execute(self) -> AccessRequest:
if self.access_request.request_ownership:
ModifyGroupUsers(
group=self.access_request.requested_group_id,
current_user_id=self.approver.id,
current_user_id=self.approver_id,
users_added_ended_at=self.ending_at,
created_reason=self.approval_reason,
owners_to_add=[self.access_request.requester_user_id],
notify=self.notify,
).execute()
else:
ModifyGroupUsers(
group=self.access_request.requested_group_id,
current_user_id=self.approver.id,
current_user_id=self.approver_id,
users_added_ended_at=self.ending_at,
created_reason=self.approval_reason,
members_to_add=[self.access_request.requester_user_id],
notify=self.notify,
).execute()

# Now handled inside ModifyGroupUsers
Expand All @@ -141,7 +152,7 @@ def execute(self) -> AccessRequest:

# self.notification_hook.access_request_completed(
# access_request=self.access_request,
# group=group.name,
# group=group,
# requester=requester,
# approvers=approvers,
# notify_requester=True,
Expand Down
32 changes: 30 additions & 2 deletions api/operations/create_access_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
)
from api.models.app_group import get_access_owners, get_app_managers
from api.models.okta_group import get_group_managers
from api.plugins import get_notification_hook
from api.operations.approve_access_request import ApproveAccessRequest
from api.operations.reject_access_request import RejectAccessRequest
from api.plugins import get_conditional_access_hook, get_notification_hook
from api.views.schemas import AuditLogSchema, EventType


Expand Down Expand Up @@ -53,6 +55,7 @@ def __init__(

self.request_approvers = db.session.query()

self.conditional_access_hook = get_conditional_access_hook()
self.notification_hook = get_notification_hook()

def execute(self) -> Optional[AccessRequest]:
Expand Down Expand Up @@ -111,9 +114,34 @@ def execute(self) -> Optional[AccessRequest]:
'group_owners' : approvers,
}))

conditional_access_responses = self.conditional_access_hook.access_request_created(
access_request=access_request,
group=self.requested_group,
requester=self.requester,
)

for response in conditional_access_responses:
if response is not None:
if response.approved:
ApproveAccessRequest(
access_request=access_request,
approval_reason=response.reason,
ending_at=response.ending_at,
notify=False,
).execute()
else:
RejectAccessRequest(
access_request=access_request,
rejection_reason=response.reason,
notify=False,
).execute()

return access_request


self.notification_hook.access_request_created(
access_request=access_request,
group=self.requested_group.name,
group=self.requested_group,
requester=self.requester,
approvers=approvers,
)
Expand Down
9 changes: 7 additions & 2 deletions api/operations/modify_group_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ def __init__(
owners_to_remove: list[str] = [],
sync_to_okta: bool = True,
current_user_id: Optional[str] = None,
created_reason: str = ''
created_reason: str = '',
notify: bool = True,
):
self.group = (
db.session.query(OktaGroup)
Expand Down Expand Up @@ -106,6 +107,7 @@ def __init__(
)

self.created_reason = created_reason
self.notify = notify

self.notification_hook = get_notification_hook()

Expand Down Expand Up @@ -592,13 +594,16 @@ def _approve_access_request(
return asyncio.create_task(self._notify_access_request(access_request))

async def _notify_access_request(self, access_request: AccessRequest) -> None:
if not self.notify:
return

requester = db.session.get(OktaUser, access_request.requester_user_id)

approvers = get_all_possible_request_approvers(access_request)

self.notification_hook.access_request_completed(
access_request=access_request,
group=access_request.requested_group.name,
group=access_request.requested_group,
requester=requester,
approvers=approvers,
notify_requester=True,
Expand Down
21 changes: 12 additions & 9 deletions api/operations/reject_access_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def __init__(
*,
access_request: AccessRequest | str,
rejection_reason: str = "",
notify: bool = True,
notify_requester: bool = True,
current_user_id: Optional[str | OktaUser] = None
):
Expand All @@ -37,6 +38,7 @@ def __init__(
self.rejecter_id = current_user_id.id

self.rejection_reason = rejection_reason
self.notify = notify
self.notify_requester = notify_requester

self.notification_hook = get_notification_hook()
Expand Down Expand Up @@ -82,16 +84,17 @@ def execute(self) -> AccessRequest:
'requester' : db.session.get(OktaUser, self.access_request.requester_user_id),
}))

requester = db.session.get(OktaUser, self.access_request.requester_user_id)
if self.notify:
requester = db.session.get(OktaUser, self.access_request.requester_user_id)

approvers = get_all_possible_request_approvers(self.access_request)
approvers = get_all_possible_request_approvers(self.access_request)

self.notification_hook.access_request_completed(
access_request=self.access_request,
group=group.name,
requester=requester,
approvers=approvers,
notify_requester=self.notify_requester,
)
self.notification_hook.access_request_completed(
access_request=self.access_request,
group=group,
requester=requester,
approvers=approvers,
notify_requester=self.notify_requester,
)

return self.access_request
10 changes: 9 additions & 1 deletion api/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import pluggy

from api.plugins.conditional_access import ConditionalAccessResponse, get_conditional_access_hook
from api.plugins.notifications import get_notification_hook

condtional_access_hook_impl = pluggy.HookimplMarker("access_conditional_access")
notification_hook_impl = pluggy.HookimplMarker("access_notifications")

__all__ = ["get_notification_hook", "notification_hook_impl"]
__all__ = [
"ConditionalAccessResponse",
"conditional_access_hook_impl",
"get_conditional_access_hook",
"get_notification_hook",
"notification_hook_impl",
]
70 changes: 70 additions & 0 deletions api/plugins/conditional_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import logging
import sys
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Generator, List, Optional

import pluggy

from api.models import AccessRequest, OktaGroup, OktaUser

conditional_access_plugin_name = "access_conditional_access"
hookspec = pluggy.HookspecMarker(conditional_access_plugin_name)
hookimpl = pluggy.HookimplMarker(conditional_access_plugin_name)

_cached_conditional_access_hook = None

logger = logging.getLogger(__name__)



@dataclass
class ConditionalAccessResponse:
approved: bool
reason: str = ''
ending_at: Optional[datetime] = None

class ConditionalAccessPluginSpec:
@hookspec
def access_request_created(self,
access_request: AccessRequest,
group: OktaGroup,
requester: OktaUser) -> Optional[ConditionalAccessResponse]:
"""Automatically approve, deny, or continue the access request."""


@hookimpl(wrapper=True)
def access_request_created(
access_request: AccessRequest,
group: OktaGroup,
requester: OktaUser
) -> Generator[Any, None, Optional[ConditionalAccessResponse]] | List[Optional[ConditionalAccessResponse]]:
try:
# Trigger exception if it exists
return (yield)
except Exception:
# Log and do not raise since request failures should not
# break the flow. The access request can still be manually
# approved or denied
logger.error("Failed to execute request created callback")

return []


def get_conditional_access_hook() -> pluggy.HookRelay:
global _cached_conditional_access_hook

if _cached_conditional_access_hook is not None:
return _cached_conditional_access_hook

pm = pluggy.PluginManager(conditional_access_plugin_name)
pm.add_hookspecs(ConditionalAccessPluginSpec)

# Register the hook wrappers
pm.register(sys.modules[__name__])

count = pm.load_setuptools_entrypoints(conditional_access_plugin_name)
print(f"Count of loaded conditional access plugins: {count}")
_cached_conditional_access_hook = pm.hook

return _cached_conditional_access_hook
Loading

0 comments on commit 4275a7f

Please sign in to comment.