Skip to content

Commit

Permalink
Role requests API (#175)
Browse files Browse the repository at this point in the history
  • Loading branch information
eguerrant authored Nov 8, 2024
1 parent 35fc7b4 commit 39473dc
Show file tree
Hide file tree
Showing 23 changed files with 2,532 additions and 6 deletions.
3 changes: 3 additions & 0 deletions api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
exception_views,
groups_views,
health_check_views,
role_requests_views,
roles_views,
tags_views,
users_views,
Expand Down Expand Up @@ -220,6 +221,8 @@ def add_headers(response: Response) -> ResponseReturnValue:
groups_views.register_docs()
app.register_blueprint(roles_views.bp)
roles_views.register_docs()
app.register_blueprint(role_requests_views.bp)
role_requests_views.register_docs()
app.register_blueprint(tags_views.bp)
tags_views.register_docs()
app.register_blueprint(webhook_views.bp)
Expand Down
2 changes: 2 additions & 0 deletions api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
OktaUserGroupMember,
RoleGroup,
RoleGroupMap,
RoleRequest,
Tag,
)

Expand All @@ -25,5 +26,6 @@
"OktaUserGroupMember",
"RoleGroup",
"RoleGroupMap",
"RoleRequest",
"Tag",
]
4 changes: 2 additions & 2 deletions api/models/access_request.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from typing import Set

from api.models.app_group import get_access_owners, get_app_managers
from api.models.core_models import AccessRequest, AppGroup, OktaUser
from api.models.core_models import AccessRequest, AppGroup, OktaUser, RoleRequest
from api.models.okta_group import get_group_managers


def get_all_possible_request_approvers(access_request: AccessRequest) -> Set[OktaUser]:
def get_all_possible_request_approvers(access_request: AccessRequest | RoleRequest) -> Set[OktaUser]:
# This will return the entire set of possible access request approvers
# to ensure that even if the resolved set of approvers changes
# we still are able to mark the request as resolved for any users
Expand Down
144 changes: 144 additions & 0 deletions api/models/core_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,14 @@ class OktaUser(db.Model):
innerjoin=True,
)

all_resolved_role_requests: Mapped[List["AccessRequest"]] = db.relationship(
"RoleRequest",
primaryjoin="OktaUser.id == RoleRequest.resolver_user_id",
back_populates="resolver",
lazy="raise_on_sql",
innerjoin=True,
)

pending_access_requests: Mapped[List["AccessRequest"]] = db.relationship(
"AccessRequest",
primaryjoin="and_(OktaUser.id == AccessRequest.requester_user_id, "
Expand Down Expand Up @@ -374,6 +382,32 @@ class OktaGroup(db.Model):
innerjoin=True,
)

# requests to join group
all_role_requests_to: Mapped[List["RoleRequest"]] = db.relationship(
"RoleRequest",
back_populates="requested_group",
primaryjoin="OktaGroup.id == RoleRequest.requested_group_id",
lazy="raise_on_sql",
)

# request by role group to join group
all_role_requests_from: Mapped[List["RoleRequest"]] = db.relationship(
"RoleRequest",
back_populates="requester_role",
primaryjoin="OktaGroup.id == RoleRequest.requester_role_id",
lazy="raise_on_sql",
)

pending_role_requests: Mapped[List["RoleRequest"]] = db.relationship(
"RoleRequest",
primaryjoin="and_(OktaGroup.id == RoleRequest.requested_group_id, "
"RoleRequest.status == 'PENDING', "
"RoleRequest.resolved_at.is_(None))",
viewonly=True,
lazy="raise_on_sql",
innerjoin=True,
)

all_group_tags: Mapped[List["OktaGroupTagMap"]] = db.relationship(
"OktaGroupTagMap",
back_populates="group",
Expand Down Expand Up @@ -479,6 +513,13 @@ class RoleGroupMap(db.Model):
"OktaUser", foreign_keys=[ended_actor_id], lazy="raise_on_sql", viewonly=True
)

role_request: Mapped["RoleRequest"] = db.relationship(
"RoleRequest",
back_populates="approved_membership",
lazy="raise_on_sql",
uselist=False,
)

@validates("group")
def validate_group(self, key: str, group: OktaGroup) -> OktaGroup:
if group.type == RoleGroup.__mapper_args__["polymorphic_identity"]:
Expand Down Expand Up @@ -725,6 +766,109 @@ class AccessRequest(db.Model):
)


class RoleRequest(db.Model):
# A 20 character random string like Okta IDs
id: Mapped[str] = mapped_column(db.Unicode(20), primary_key=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(db.DateTime(), nullable=False, default=db.func.now())
updated_at: Mapped[datetime] = mapped_column(
db.DateTime(), nullable=False, default=db.func.now(), onupdate=db.func.now()
)
resolved_at: Mapped[Optional[datetime]] = mapped_column(db.DateTime())

status: Mapped[AccessRequestStatus] = mapped_column(
db.Enum(AccessRequestStatus),
nullable=False,
default=AccessRequestStatus.PENDING,
)

# must be an owner of the role
requester_user_id: Mapped[str] = mapped_column(db.Unicode(50), db.ForeignKey("okta_user.id"))
# role to be added to the requested group
requester_role_id: Mapped[str] = mapped_column(db.Unicode(50), db.ForeignKey("okta_group.id"))
requested_group_id: Mapped[str] = mapped_column(db.Unicode(50), db.ForeignKey("okta_group.id"))
request_ownership: Mapped[bool] = mapped_column(db.Boolean, nullable=False, default=False)
request_reason: Mapped[str] = mapped_column(db.Unicode(1024), nullable=False, default="")
request_ending_at: Mapped[Optional[datetime]] = mapped_column(db.DateTime())

resolver_user_id: Mapped[Optional[str]] = mapped_column(db.Unicode(50), db.ForeignKey("okta_user.id"))
resolution_reason: Mapped[str] = mapped_column(db.Unicode(1024), nullable=False, default="")

approval_ending_at: Mapped[Optional[datetime]] = mapped_column(db.DateTime())

# See https://stackoverflow.com/a/60840921
approved_membership_id: Mapped[Optional[int]] = mapped_column(
db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("role_group_map.id"),
)

requester: Mapped[OktaUser] = db.relationship(
"OktaUser",
primaryjoin="OktaUser.id == RoleRequest.requester_user_id",
viewonly=True,
lazy="raise_on_sql",
innerjoin=True,
)

active_requester: Mapped[OktaUser] = db.relationship(
"OktaUser",
primaryjoin="and_(OktaUser.id == RoleRequest.requester_user_id, " "OktaUser.deleted_at.is_(None))",
viewonly=True,
lazy="raise_on_sql",
innerjoin=True,
)

requester_role: Mapped[OktaGroup] = db.relationship(
"OktaGroup",
back_populates="all_role_requests_from",
foreign_keys=[requester_role_id],
lazy="raise_on_sql",
)

active_requester_role: Mapped[OktaGroup] = db.relationship(
"OktaGroup",
primaryjoin="and_(OktaGroup.id == RoleRequest.requested_group_id, " "OktaGroup.deleted_at.is_(None))",
viewonly=True,
lazy="raise_on_sql",
innerjoin=True,
)

requested_group: Mapped[OktaGroup] = db.relationship(
"OktaGroup",
back_populates="all_role_requests_to",
foreign_keys=[requested_group_id],
lazy="raise_on_sql",
)

active_requested_group: Mapped[OktaGroup] = db.relationship(
"OktaGroup",
primaryjoin="and_(OktaGroup.id == RoleRequest.requested_group_id, " "OktaGroup.deleted_at.is_(None))",
viewonly=True,
lazy="raise_on_sql",
innerjoin=True,
)

resolver: Mapped[OktaUser] = db.relationship(
"OktaUser",
back_populates="all_resolved_role_requests",
foreign_keys=[resolver_user_id],
lazy="raise_on_sql",
)

active_resolver: Mapped[OktaUser] = db.relationship(
"OktaUser",
primaryjoin="and_(OktaUser.id == RoleRequest.resolver_user_id, " "OktaUser.deleted_at.is_(None))",
viewonly=True,
lazy="raise_on_sql",
)

approved_membership: Mapped[RoleGroupMap] = db.relationship(
"RoleGroupMap",
back_populates="role_request",
foreign_keys=[approved_membership_id],
lazy="raise_on_sql",
)


class TagConstraint:
def __init__(
self,
Expand Down
6 changes: 6 additions & 0 deletions api/operations/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from api.operations.approve_access_request import ApproveAccessRequest
from api.operations.create_access_request import CreateAccessRequest
from api.operations.reject_access_request import RejectAccessRequest
from api.operations.approve_role_request import ApproveRoleRequest
from api.operations.create_role_request import CreateRoleRequest
from api.operations.reject_role_request import RejectRoleRequest
from api.operations.create_group import CreateGroup
from api.operations.create_app import CreateApp
from api.operations.create_tag import CreateTag
Expand All @@ -20,6 +23,9 @@
"CreateAccessRequest",
"ApproveAccessRequest",
"RejectAccessRequest",
"CreateRoleRequest",
"ApproveRoleRequest",
"RejectRoleRequest",
"CreateApp",
"CreateTag",
"DeleteApp",
Expand Down
131 changes: 131 additions & 0 deletions api/operations/approve_role_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
from datetime import datetime
from typing import Optional

from flask import current_app, has_request_context, request
from sqlalchemy.orm import joinedload, selectin_polymorphic

from api.extensions import db
from api.models import AccessRequestStatus, AppGroup, OktaGroup, OktaUser, RoleGroup, RoleRequest
from api.operations.constraints import CheckForReason
from api.operations.modify_role_groups import ModifyRoleGroups
from api.plugins import get_notification_hook
from api.views.schemas import AuditLogSchema, EventType


class ApproveRoleRequest:
def __init__(
self,
*,
role_request: RoleRequest | str,
approver_user: Optional[OktaUser | str] = None,
approval_reason: str = "",
ending_at: Optional[datetime] = None,
notify: bool = True,
):
self.role_request = (
RoleRequest.query.options(
joinedload(RoleRequest.active_requested_group), joinedload(RoleRequest.active_requester_role)
)
.filter(RoleRequest.id == (role_request if isinstance(role_request, str) else role_request.id))
.first()
)

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_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) -> RoleRequest:
# Don't allow approving a request that is already resolved
if self.role_request.status != AccessRequestStatus.PENDING or self.role_request.resolved_at is not None:
return self.role_request

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

# Don't allow approving a request if the reason is invalid and required
valid, _ = CheckForReason(
group=self.role_request.requester_role_id,
reason=self.approval_reason,
members_to_add=[self.role_request.requested_group_id] if not self.role_request.request_ownership else [],
owners_to_add=[self.role_request.requested_group_id] if self.role_request.request_ownership else [],
).execute_for_role()
if not valid:
return self.role_request

# Don't allow approving a request if the requester role is deleted
requester = db.session.get(RoleGroup, self.role_request.requester_role_id)
if requester is None or requester.deleted_at is not None:
return self.role_request

# Don't allow approving a request for an a deleted or unmanaged group
if self.role_request.active_requested_group is None:
return self.role_request
if not self.role_request.active_requested_group.is_managed:
return self.role_request

db.session.commit()

# Audit logging
group = (
db.session.query(OktaGroup)
.options(selectin_polymorphic(OktaGroup, [AppGroup]), joinedload(AppGroup.app))
.filter(OktaGroup.deleted_at.is_(None))
.filter(OktaGroup.id == self.role_request.requested_group_id)
.first()
)

context = has_request_context()

current_app.logger.info(
AuditLogSchema().dumps(
{
"event_type": EventType.role_request_approve,
"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,
"group": group,
"role_request": self.role_request,
"requester": db.session.get(OktaUser, self.role_request.requester_user_id),
}
)
)

if self.role_request.request_ownership:
ModifyRoleGroups(
role_group=self.role_request.requester_role,
groups_added_ended_at=self.ending_at,
owner_groups_to_add=[self.role_request.requested_group_id],
current_user_id=self.approver_id,
created_reason=self.approval_reason,
notify=self.notify,
).execute()
else:
ModifyRoleGroups(
role_group=self.role_request.requester_role,
groups_added_ended_at=self.ending_at,
groups_to_add=[self.role_request.requested_group_id],
current_user_id=self.approver_id,
created_reason=self.approval_reason,
notify=self.notify,
).execute()

return self.role_request
Loading

0 comments on commit 39473dc

Please sign in to comment.