From 2d61da94c4f4b2b38376ad5e496349181a8f21df Mon Sep 17 00:00:00 2001 From: Jonathan Le Date: Thu, 7 Nov 2024 14:39:11 -0800 Subject: [PATCH 01/13] Pin PG to version 16 (#187) --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3601bb77..8ff595ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: - postgres postgres: - image: postgres:latest + image: postgres:16 container_name: postgres env_file: - .env.psql From 35fc7b48edae4b8daeb21bffee74d8f3c5467c3a Mon Sep 17 00:00:00 2001 From: Jonathan Le Date: Thu, 7 Nov 2024 14:39:48 -0800 Subject: [PATCH 02/13] formatting changes to address FromAsCasing warning (#188) --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 633d1f53..92754ea0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG PUSH_SENTRY_RELEASE="false" # Build step #1: build the React front end -FROM node:23-alpine as build-step +FROM node:23-alpine AS build-step ARG SENTRY_RELEASE="" WORKDIR /app ENV PATH /app/node_modules/.bin:$PATH @@ -17,7 +17,7 @@ ENV REACT_APP_API_SERVER_URL "" RUN npm run build # Optional build step #2: upload the source maps by pushing a release to sentry -FROM getsentry/sentry-cli:2 as sentry +FROM getsentry/sentry-cli:2 AS sentry ARG SENTRY_RELEASE="" RUN --mount=type=secret,id=SENTRY_CLI_RC \ cp /run/secrets/SENTRY_CLI_RC ~/.sentryclirc @@ -29,7 +29,7 @@ RUN sentry-cli releases finalize ${SENTRY_RELEASE} RUN touch sentry # Build step #3: build the API with the client as static files -FROM python:3.13 as false +FROM python:3.13 AS false ARG SENTRY_RELEASE="" WORKDIR /app COPY --from=build-step /app/build ./build @@ -41,7 +41,7 @@ COPY migrations/ ./migrations/ RUN pip install -r ./api/requirements.txt # Build an image that includes the optional sentry release push build step -FROM false as true +FROM false AS true COPY --from=sentry /app/sentry ./sentry # Final build step: copy the API and the client from the previous steps From 39473dc01bc5b76abbb499cfa593d886319859de Mon Sep 17 00:00:00 2001 From: eguerrant <141771735+eguerrant@users.noreply.github.com> Date: Fri, 8 Nov 2024 09:32:24 -0800 Subject: [PATCH 03/13] Role requests API (#175) --- api/app.py | 3 + api/models/__init__.py | 2 + api/models/access_request.py | 4 +- api/models/core_models.py | 144 +++ api/operations/__init__.py | 6 + api/operations/approve_role_request.py | 131 +++ api/operations/create_role_request.py | 180 +++ api/operations/modify_role_groups.py | 63 + api/operations/reject_role_request.py | 105 ++ api/plugins/conditional_access.py | 26 +- api/plugins/notifications.py | 60 +- api/views/resources/__init__.py | 3 + api/views/resources/role_request.py | 452 +++++++ api/views/role_requests_views.py | 22 + api/views/schemas/__init__.py | 9 + api/views/schemas/audit_logs.py | 17 + api/views/schemas/core_schemas.py | 72 ++ api/views/schemas/pagination.py | 14 + api/views/schemas/role_requests.py | 52 + .../versions/6d2a03b326f9_role_requests.py | 103 ++ tests/conftest.py | 2 + tests/factories.py | 21 +- tests/test_role_request.py | 1047 +++++++++++++++++ 23 files changed, 2532 insertions(+), 6 deletions(-) create mode 100644 api/operations/approve_role_request.py create mode 100644 api/operations/create_role_request.py create mode 100644 api/operations/reject_role_request.py create mode 100644 api/views/resources/role_request.py create mode 100644 api/views/role_requests_views.py create mode 100644 api/views/schemas/role_requests.py create mode 100644 migrations/versions/6d2a03b326f9_role_requests.py create mode 100644 tests/test_role_request.py diff --git a/api/app.py b/api/app.py index 67c7842d..38557d0d 100644 --- a/api/app.py +++ b/api/app.py @@ -27,6 +27,7 @@ exception_views, groups_views, health_check_views, + role_requests_views, roles_views, tags_views, users_views, @@ -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) diff --git a/api/models/__init__.py b/api/models/__init__.py index 0cb67ea4..5e958d6a 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -10,6 +10,7 @@ OktaUserGroupMember, RoleGroup, RoleGroupMap, + RoleRequest, Tag, ) @@ -25,5 +26,6 @@ "OktaUserGroupMember", "RoleGroup", "RoleGroupMap", + "RoleRequest", "Tag", ] diff --git a/api/models/access_request.py b/api/models/access_request.py index 94b04eae..0aaf3861 100644 --- a/api/models/access_request.py +++ b/api/models/access_request.py @@ -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 diff --git a/api/models/core_models.py b/api/models/core_models.py index f699c94a..602bc2e0 100644 --- a/api/models/core_models.py +++ b/api/models/core_models.py @@ -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, " @@ -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", @@ -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"]: @@ -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, diff --git a/api/operations/__init__.py b/api/operations/__init__.py index 6cd4f116..b099a0ce 100644 --- a/api/operations/__init__.py +++ b/api/operations/__init__.py @@ -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 @@ -20,6 +23,9 @@ "CreateAccessRequest", "ApproveAccessRequest", "RejectAccessRequest", + "CreateRoleRequest", + "ApproveRoleRequest", + "RejectRoleRequest", "CreateApp", "CreateTag", "DeleteApp", diff --git a/api/operations/approve_role_request.py b/api/operations/approve_role_request.py new file mode 100644 index 00000000..8966fb30 --- /dev/null +++ b/api/operations/approve_role_request.py @@ -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 diff --git a/api/operations/create_role_request.py b/api/operations/create_role_request.py new file mode 100644 index 00000000..b0989c6e --- /dev/null +++ b/api/operations/create_role_request.py @@ -0,0 +1,180 @@ +import random +import string +from datetime import datetime +from typing import Optional + +from flask import current_app, has_request_context, request +from sqlalchemy.orm import joinedload, selectin_polymorphic, selectinload + +from api.extensions import db +from api.models import ( + AccessRequestStatus, + AppGroup, + OktaGroup, + OktaGroupTagMap, + OktaUser, + RoleGroup, + RoleRequest, +) +from api.models.app_group import get_access_owners, get_app_managers +from api.models.okta_group import get_group_managers +from api.operations.approve_role_request import ApproveRoleRequest +from api.operations.reject_role_request import RejectRoleRequest +from api.plugins import get_conditional_access_hook, get_notification_hook +from api.views.schemas import AuditLogSchema, EventType + + +class CreateRoleRequest: + def __init__( + self, + *, + requester_user: OktaUser | str, + requester_role: OktaGroup | str, + requested_group: OktaGroup | str, + request_ownership: bool = False, + request_reason: str = "", + request_ending_at: Optional[datetime] = None, + ): + self.id = self.__generate_id() + + if isinstance(requester_user, str): + self.requester = db.session.get(OktaUser, requester_user) + else: + self.requester = requester_user + + if isinstance(requester_role, str): + self.requester_role = ( + RoleGroup.query.filter(RoleGroup.deleted_at.is_(None)).filter(RoleGroup.id == requester_role).first() + ) + else: + self.requester_role = requester_role + + self.requested_group = ( + db.session.query(OktaGroup) + .options(selectin_polymorphic(OktaGroup, [AppGroup]), joinedload(AppGroup.app)) + .filter(OktaGroup.deleted_at.is_(None)) + .filter(OktaGroup.id == (requested_group if isinstance(requested_group, str) else requested_group.id)) + .first() + ) + + self.request_ownership = request_ownership + self.request_reason = request_reason + self.request_ending_at = request_ending_at + + self.conditional_access_hook = get_conditional_access_hook() + self.notification_hook = get_notification_hook() + + def execute(self) -> Optional[RoleRequest]: + # Don't allow creating a request for an unmanaged group + if not self.requested_group.is_managed: + return None + + # Don't allow creating a request for a role group + if type(self.requested_group) is RoleGroup: + return None + + role_request = RoleRequest( + id=self.id, + status=AccessRequestStatus.PENDING, + requester_user_id=self.requester.id, + requester_role_id=self.requester_role.id, + requested_group_id=self.requested_group.id, + request_ownership=self.request_ownership, + request_reason=self.request_reason, + request_ending_at=self.request_ending_at, + ) + + db.session.add(role_request) + db.session.commit() + + # Fetch the users to notify + approvers = get_group_managers(self.requested_group.id) + + # If there are no approvers, try to get the app managers + # or if the only approver is the requester, try to get the app managers + if ( + (len(approvers) == 0 and type(self.requested_group) is AppGroup) + or (len(approvers) == 1 and approvers[0].id == self.requester.id) + and type(self.requested_group) is AppGroup + ): + approvers = get_app_managers(self.requested_group.app_id) + + # If there are still no approvers, try to get the access owners + if len(approvers) == 0 or (len(approvers) == 1 and approvers[0].id == self.requester.id): + approvers = get_access_owners() + + group = ( + db.session.query(OktaGroup) + .options( + selectin_polymorphic(OktaGroup, [AppGroup, RoleGroup]), + joinedload(AppGroup.app), + selectinload(OktaGroup.active_group_tags).options( + joinedload(OktaGroupTagMap.active_app_tag_mapping), joinedload(OktaGroupTagMap.enabled_active_tag) + ), + ) + .filter(OktaGroup.deleted_at.is_(None)) + .filter(OktaGroup.id == self.requested_group.id) + .first() + ) + + # Audit logging + context = has_request_context() + + current_app.logger.info( + AuditLogSchema(exclude=["request.resolution_reason", "request.approval_ending_at"]).dumps( + { + "event_type": EventType.role_request_create, + "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.requester.id, + "current_user_email": self.requester.email, + "group": group, + "role_request": role_request, + "requester": self.requester, + "group_owners": approvers, + } + ) + ) + + conditional_access_responses = self.conditional_access_hook.role_request_created( + role_request=role_request, + role=self.requester_role, + group=self.requested_group, + group_tags=[active_tag_map.enabled_active_tag for active_tag_map in group.active_group_tags], + requester=self.requester, + requester_role=self.requester_role, + ) + + for response in conditional_access_responses: + if response is not None: + if response.approved: + ApproveRoleRequest( + role_request=role_request, + approval_reason=response.reason, + ending_at=response.ending_at, + notify=False, + ).execute() + else: + RejectRoleRequest( + role_request=role_request, + rejection_reason=response.reason, + notify=False, + ).execute() + + return role_request + + self.notification_hook.access_role_request_created( + role_request=role_request, + role=self.requester_role, + group=self.requested_group, + requester=self.requester, + approvers=approvers, + ) + + return role_request + + # Generate a 20 character alphanumeric ID similar to Okta IDs for users and groups + def __generate_id(self) -> str: + return "".join(random.choices(string.ascii_letters, k=20)) diff --git a/api/operations/modify_role_groups.py b/api/operations/modify_role_groups.py index 0f8583ef..fa8261a2 100644 --- a/api/operations/modify_role_groups.py +++ b/api/operations/modify_role_groups.py @@ -15,6 +15,7 @@ OktaUserGroupMember, RoleGroup, RoleGroupMap, + RoleRequest, Tag, ) from api.models.access_request import get_all_possible_request_approvers @@ -37,6 +38,7 @@ def __init__( sync_to_okta: bool = True, current_user_id: Optional[str] = None, created_reason: str = "", + notify: bool = True, ): if isinstance(role_group, str): self.role = ( @@ -98,6 +100,8 @@ def __init__( self.created_reason = created_reason + self.notify = notify + self.notification_hook = get_notification_hook() def execute(self) -> RoleGroup: @@ -417,6 +421,36 @@ async def _execute(self) -> RoleGroup: ) ) + # Approve any pending role requests for memberships granted by this operation + pending_role_requests_query = ( + RoleRequest.query.filter(RoleRequest.status == AccessRequestStatus.PENDING) + .filter(RoleRequest.resolved_at.is_(None)) + .filter(RoleRequest.requester_role == self.role) + ) + + added_group_ids = [group.id for group in self.groups_to_add] + pending_role_memberships = ( + pending_role_requests_query.filter(RoleRequest.request_ownership.is_(False)) + .filter(RoleRequest.requested_group_id.in_(added_group_ids)) + .all() + ) + for role_request in pending_role_memberships: + async_tasks.append( + self._approve_role_request(role_request, role_memberships_added[role_request.requested_group_id]) + ) + + # Approve any pending role requests for ownerships granted by this operation + added_owner_group_ids = [group.id for group in self.owner_groups_to_add] + pending_role_ownerships = ( + pending_role_requests_query.filter(RoleRequest.request_ownership.is_(True)) + .filter(RoleRequest.requested_group_id.in_(added_owner_group_ids)) + .all() + ) + for role_request in pending_role_ownerships: + async_tasks.append( + self._approve_role_request(role_request, role_ownerships_added[role_request.requested_group_id]) + ) + db.session.commit() # Commit all changes @@ -490,3 +524,32 @@ async def _notify_access_request(self, access_request: AccessRequest) -> None: approvers=approvers, notify_requester=True, ) + + def _approve_role_request( + self, role_request: RoleRequest, added_role_group_map: RoleGroupMap + ) -> asyncio.Task[None]: + role_request.status = AccessRequestStatus.APPROVED + role_request.resolved_at = db.func.now() + role_request.resolver_user_id = self.current_user_id + role_request.resolution_reason = self.created_reason + role_request.approval_ending_at = added_role_group_map.ended_at + role_request.approved_membership_id = added_role_group_map.id + + return asyncio.create_task(self._notify_role_request(role_request)) + + async def _notify_role_request(self, role_request: RoleRequest) -> None: + if not self.notify: + return + + requester = db.session.get(OktaUser, role_request.requester_user_id) + + approvers = get_all_possible_request_approvers(role_request) + + self.notification_hook.access_role_request_completed( + role_request=role_request, + role=role_request.requester_role, + group=role_request.requested_group, + requester=requester, + approvers=approvers, + notify_requester=True, + ) diff --git a/api/operations/reject_role_request.py b/api/operations/reject_role_request.py new file mode 100644 index 00000000..b2f1535f --- /dev/null +++ b/api/operations/reject_role_request.py @@ -0,0 +1,105 @@ +from typing import Optional + +from flask import current_app, has_request_context, request +from sqlalchemy import nullsfirst +from sqlalchemy.orm import joinedload, selectin_polymorphic + +from api.extensions import db +from api.models import AccessRequestStatus, AppGroup, OktaGroup, OktaUser, RoleRequest +from api.models.access_request import get_all_possible_request_approvers +from api.plugins import get_notification_hook +from api.views.schemas import AuditLogSchema, EventType + + +class RejectRoleRequest: + def __init__( + self, + *, + role_request: RoleRequest | str, + rejection_reason: str = "", + notify: bool = True, + notify_requester: bool = True, + current_user_id: Optional[str | OktaUser] = None, + ): + if isinstance(role_request, str): + self.role_request = db.session.get(RoleRequest, role_request) + else: + self.role_request = role_request + + if current_user_id is None: + self.rejecter_id = None + elif isinstance(current_user_id, str): + self.rejecter_id = getattr( + OktaUser.query.filter(OktaUser.deleted_at.is_(None)).filter(OktaUser.id == current_user_id).first(), + "id", + None, + ) + else: + 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() + + 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 + + self.role_request.status = AccessRequestStatus.REJECTED + self.role_request.resolved_at = db.func.now() + self.role_request.resolver_user_id = self.rejecter_id + self.role_request.resolution_reason = self.rejection_reason + + db.session.commit() + + # Audit logging + email = None + if self.rejecter_id is not None: + email = getattr(db.session.get(OktaUser, self.rejecter_id), "email", None) + + group = ( + db.session.query(OktaGroup) + .options(selectin_polymorphic(OktaGroup, [AppGroup]), joinedload(AppGroup.app)) + .filter(OktaGroup.id == self.role_request.requested_group_id) + .order_by(nullsfirst(OktaGroup.deleted_at.desc())) + .first() + ) + + context = has_request_context() + + current_app.logger.info( + AuditLogSchema(exclude=["request.approval_ending_at"]).dumps( + { + "event_type": EventType.role_request_reject, + "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.rejecter_id, + "current_user_email": email, + "group": group, + "role_request": self.role_request, + "requester": db.session.get(OktaUser, self.role_request.requester_user_id), + } + ) + ) + + if self.notify: + requester = db.session.get(OktaUser, self.role_request.requester_user_id) + requester_role = db.session.get(OktaGroup, self.role_request.requester_role_id) + + approvers = get_all_possible_request_approvers(self.role_request) + + self.notification_hook.access_role_request_completed( + role_request=self.role_request, + role=requester_role, + group=group, + requester=requester, + approvers=approvers, + notify_requester=self.notify_requester, + ) + + return self.role_request diff --git a/api/plugins/conditional_access.py b/api/plugins/conditional_access.py index 10617e04..341c3999 100644 --- a/api/plugins/conditional_access.py +++ b/api/plugins/conditional_access.py @@ -6,7 +6,7 @@ import pluggy -from api.models import AccessRequest, OktaGroup, OktaUser, Tag +from api.models import AccessRequest, OktaGroup, OktaUser, RoleGroup, RoleRequest, Tag conditional_access_plugin_name = "access_conditional_access" hookspec = pluggy.HookspecMarker(conditional_access_plugin_name) @@ -31,6 +31,12 @@ def access_request_created( ) -> Optional[ConditionalAccessResponse]: """Automatically approve, deny, or continue the access request.""" + @hookspec + def role_request_created( + self, role_request: RoleRequest, role: RoleGroup, group: OktaGroup, group_tags: List[Tag], requester: OktaUser + ) -> Optional[ConditionalAccessResponse]: + """Automatically approve, deny, or continue the access request.""" + @hookimpl(wrapper=True) def access_request_created( @@ -43,7 +49,23 @@ def access_request_created( # Log and do not raise since request failures should not # break the flow. The access request can still be manually # approved or denied - logger.exception("Failed to execute request created callback") + logger.exception("Failed to execute access request created callback") + + return [] + + +@hookimpl(wrapper=True) +def role_request_created( + role_request: RoleRequest, role: RoleGroup, group: OktaGroup, group_tags: List[Tag], 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.exception("Failed to execute role request created callback") return [] diff --git a/api/plugins/notifications.py b/api/plugins/notifications.py index e14c9aac..04c35eb5 100644 --- a/api/plugins/notifications.py +++ b/api/plugins/notifications.py @@ -5,7 +5,7 @@ import pluggy -from api.models import AccessRequest, OktaGroup, OktaUser, RoleGroup +from api.models import AccessRequest, OktaGroup, OktaUser, RoleGroup, RoleRequest notification_plugin_name = "access_notifications" hookspec = pluggy.HookspecMarker(notification_plugin_name) @@ -51,6 +51,29 @@ def access_expiring_owner( ) -> None: """Notify group owners that individuals or roles access to a group is expiring soon""" + @hookspec + def access_role_request_created( + self, + role_request: RoleRequest, + role: RoleGroup, + group: OktaGroup, + requester: OktaUser, + approvers: list[OktaUser], + ) -> None: + """Notify the approvers of the role request.""" + + @hookspec + def access_role_request_completed( + self, + role_request: RoleRequest, + role: RoleGroup, + group: OktaGroup, + requester: OktaUser, + approvers: list[OktaUser], + notify_requester: bool, + ) -> None: + """Notify the requester that their role request has been processed.""" + @hookimpl(wrapper=True) def access_request_created( @@ -112,6 +135,41 @@ def access_expiring_owner( logger.exception("Failed to execute access expiring for owner notification callback") +@hookimpl(wrapper=True) +def access_role_request_created( + role_request: RoleRequest, + role: RoleGroup, + group: OktaGroup, + requester: OktaUser, + approvers: list[OktaUser], +) -> Generator[None, None, None]: + try: + return (yield) + except Exception: + # Log and do not raise since notification failures should not + # break the flow. Users can still manually ping approvers + # to process their request from the UI + logger.exception("Failed to execute role request created notification callback") + + +@hookimpl(wrapper=True) +def access_role_request_completed( + role_request: RoleRequest, + role: RoleGroup, + group: OktaGroup, + requester: OktaUser, + approvers: list[OktaUser], + notify_requester: bool, +) -> Generator[None, None, None]: + try: + return (yield) + except Exception: + # Log and do not raise since notification failures should not + # break the flow. Users can still manually ping approvers + # to process their request from the UI + logger.exception("Failed to execute role request completed notification callback") + + def get_notification_hook() -> pluggy.HookRelay: global _cached_notification_hook diff --git a/api/views/resources/__init__.py b/api/views/resources/__init__.py index 16664e33..6620b121 100644 --- a/api/views/resources/__init__.py +++ b/api/views/resources/__init__.py @@ -4,6 +4,7 @@ from api.views.resources.bug import SentryProxyResource from api.views.resources.group import GroupAuditResource, GroupList, GroupMemberResource, GroupResource from api.views.resources.role import RoleAuditResource, RoleList, RoleMemberResource, RoleResource +from api.views.resources.role_request import RoleRequestList, RoleRequestResource from api.views.resources.tag import TagList, TagResource from api.views.resources.user import UserAuditResource, UserList, UserResource from api.views.resources.webhook import OktaWebhookResource @@ -23,6 +24,8 @@ "RoleList", "RoleMemberResource", "RoleResource", + "RoleRequestList", + "RoleRequestResource", "SentryProxyResource", "TagList", "TagResource", diff --git a/api/views/resources/role_request.py b/api/views/resources/role_request.py new file mode 100644 index 00000000..f98a6b8f --- /dev/null +++ b/api/views/resources/role_request.py @@ -0,0 +1,452 @@ +from flask import abort, g, request +from flask.typing import ResponseReturnValue +from flask_apispec import MethodResource +from sqlalchemy import String, cast +from sqlalchemy.orm import aliased, joinedload, selectin_polymorphic, selectinload, with_polymorphic + +from api.apispec import FlaskApiSpecDecorators +from api.authorization import AuthorizationHelpers +from api.extensions import db +from api.models import ( + AccessRequestStatus, + App, + AppGroup, + OktaGroup, + OktaGroupTagMap, + OktaUser, + OktaUserGroupMember, + RoleGroup, + RoleRequest, +) +from api.operations import ( + ApproveRoleRequest, + CreateRoleRequest, + RejectRoleRequest, +) +from api.operations.constraints import ( + CheckForReason, +) +from api.pagination import paginate +from api.views.schemas import ( + CreateRoleRequestSchema, + ResolveRoleRequestSchema, + RoleRequestPaginationSchema, + RoleRequestSchema, + SearchRoleRequestPaginationRequestSchema, +) + +# Use selectinload for one-to-many eager loading and used joinedload for one-to-one eager loading +ROLE_ASSOCIATED_GROUP_TYPES = with_polymorphic( + OktaGroup, + [ + AppGroup, + ], + flat=True, +) +DEFAULT_LOAD_OPTIONS = ( + joinedload(RoleRequest.requester), + joinedload(RoleRequest.requester_role), + joinedload(RoleRequest.requested_group).options( + # Role requests can only be for OktaGroups and AppGroups + selectin_polymorphic(OktaGroup, [AppGroup]), + joinedload(AppGroup.app), + selectinload(OktaGroup.active_group_tags).options( + joinedload(OktaGroupTagMap.active_tag), joinedload(OktaGroupTagMap.active_app_tag_mapping) + ), + ), + joinedload(RoleRequest.resolver), +) + + +class RoleRequestResource(MethodResource): + @FlaskApiSpecDecorators.response_schema(RoleRequestSchema) + def get(self, role_request_id: str) -> ResponseReturnValue: + schema = RoleRequestSchema( + only=( + "id", + "created_at", + "updated_at", + "resolved_at", + "status", + "request_ownership", + "request_reason", + "request_ending_at", + "requester.id", + "requester.email", + "requester.first_name", + "requester.last_name", + "requester.display_name", + "requester.deleted_at", + "requester_role.id", + "requester_role.name", + "requester_role.deleted_at", + "requested_group.id", + "requested_group.type", + "requested_group.name", + "requested_group.deleted_at", + "requested_group.is_owner", + "requested_group.app.id", + "requested_group.app.name", + "requested_group.app", + "requested_group.active_group_tags", + "requested_group.active_role_associated_group_member_mappings", + "requested_group.active_role_associated_group_owner_mappings", + "resolver.id", + "resolver.email", + "resolver.first_name", + "resolver.last_name", + "resolver.display_name", + "resolution_reason", + "approval_ending_at", + ) + ) + role_request = ( + RoleRequest.query.options(DEFAULT_LOAD_OPTIONS).filter(RoleRequest.id == role_request_id).first_or_404() + ) + return schema.dump(role_request) + + @FlaskApiSpecDecorators.request_schema(ResolveRoleRequestSchema) + @FlaskApiSpecDecorators.response_schema(RoleRequestSchema) + def put(self, role_request_id: str) -> ResponseReturnValue: + role_request = ( + RoleRequest.query.options(joinedload(RoleRequest.active_requested_group)) + .filter(RoleRequest.id == role_request_id) + .first_or_404() + ) + + role_request_args = ResolveRoleRequestSchema().load(request.get_json()) + + # Check if the current user is the user who created the request (they can always reject their own requests) + if role_request.requester_user_id == g.current_user_id: + if role_request_args["approved"]: + abort(403, "Users cannot approve their own requests") + # Otherwise check if the current user can manage the requested group for the role request + elif not AuthorizationHelpers.can_manage_group(role_request.active_requested_group): + abort(403, "Current user is not allowed to perform this action") + + # Check group tags to see if a reason is required for approval + if role_request_args["approved"]: + valid, err_message = CheckForReason( + group=role_request.requester_role_id, + reason=role_request_args.get("reason"), + members_to_add=[role_request.requested_group_id] if not role_request.request_ownership else [], + owners_to_add=[role_request.requested_group_id] if role_request.request_ownership else [], + ).execute_for_role() + if not valid: + abort(400, err_message) + + if role_request.status != AccessRequestStatus.PENDING or role_request.resolved_at is not None: + abort(400, "Role request is not pending") + + if role_request_args["approved"]: + if not role_request.requested_group.is_managed: + abort( + 400, + "Groups not managed by Access cannot be modified", + ) + ApproveRoleRequest( + role_request=role_request, + approver_user=g.current_user_id, + approval_reason=role_request_args.get("reason"), + ending_at=role_request_args.get("ending_at"), + ).execute() + else: + RejectRoleRequest( + role_request=role_request, + rejection_reason=role_request_args.get("reason"), + notify_requester=role_request.requester_user_id != g.current_user_id, + current_user_id=g.current_user_id, + ).execute() + + role_request = RoleRequest.query.options(DEFAULT_LOAD_OPTIONS).filter(RoleRequest.id == role_request.id).first() + return RoleRequestSchema( + only=( + "id", + "created_at", + "updated_at", + "resolved_at", + "status", + "request_ownership", + "request_reason", + "request_ending_at", + "requester.id", + "requester.email", + "requester.first_name", + "requester.last_name", + "requester.display_name", + "requester.deleted_at", + "requester_role.id", + "requester_role.name", + "requester_role.deleted_at", + "requested_group.id", + "requested_group.type", + "requested_group.name", + "requested_group.deleted_at", + "resolver.id", + "resolver.email", + "resolver.first_name", + "resolver.last_name", + "resolver.display_name", + "resolution_reason", + ) + ).dump(role_request) + + +class RoleRequestList(MethodResource): + @FlaskApiSpecDecorators.request_schema(SearchRoleRequestPaginationRequestSchema, location="query") + @FlaskApiSpecDecorators.response_schema(RoleRequestPaginationSchema) + def get(self) -> ResponseReturnValue: + search_args = SearchRoleRequestPaginationRequestSchema().load(request.args) + + query = RoleRequest.query.options(DEFAULT_LOAD_OPTIONS).order_by(RoleRequest.created_at.desc()) + + if "status" in search_args: + query = query.filter(RoleRequest.status == search_args["status"]) + + if "requester_user_id" in search_args: + if search_args["requester_user_id"] == "@me": + query = query.filter(RoleRequest.requester_user_id == g.current_user_id) + else: + requester_alias = aliased(OktaUser) + query = query.join(RoleRequest.requester.of_type(requester_alias)).filter( + db.or_( + RoleRequest.requester_user_id == search_args["requester_user_id"], + requester_alias.email.ilike(search_args["requester_user_id"]), + ) + ) + + if "requester_role_id" in search_args: + query = query.join(RoleRequest.requester_role).filter( + db.or_( + RoleRequest.requester_role_id == search_args["requester_role_id"], + RoleGroup.name.ilike(search_args["requester_role_id"]), + ) + ) + + if "requested_group_id" in search_args: + query = query.join(RoleRequest.requested_group).filter( + db.or_( + RoleRequest.requested_group_id == search_args["requested_group_id"], + OktaGroup.name.ilike(search_args["requested_group_id"]), + ) + ) + + if "assignee_user_id" in search_args: + assignee_user_id = search_args["assignee_user_id"] + if search_args["assignee_user_id"] == "@me": + assignee_user_id = g.current_user_id + + assignee_user = OktaUser.query.filter( + db.or_( + OktaUser.id == assignee_user_id, + OktaUser.email.ilike(assignee_user_id), + ) + ).first() + + if assignee_user is not None: + groups_owned_subquery = ( + db.session.query(OktaGroup.id) + .options(selectinload(OktaGroup.active_user_ownerships)) + .join(OktaGroup.active_user_ownerships) + .filter(OktaGroup.deleted_at.is_(None)) + .filter(OktaUserGroupMember.user_id == assignee_user.id) + .subquery() + ) + owner_app_group_alias = aliased(AppGroup) + app_groups_owned_subquery = ( + db.session.query(AppGroup.id) + .options( + joinedload(AppGroup.app) + .joinedload(App.active_owner_app_groups.of_type(owner_app_group_alias)) + .selectinload(owner_app_group_alias.active_user_ownerships) + ) + .join(AppGroup.app) + .join(App.active_owner_app_groups.of_type(owner_app_group_alias)) + .join(owner_app_group_alias.active_user_ownerships) + .filter(AppGroup.deleted_at.is_(None)) + .filter(OktaUserGroupMember.user_id == assignee_user.id) + .subquery() + ) + + query = query.join(RoleRequest.requested_group).filter( + db.or_( + OktaGroup.id.in_(groups_owned_subquery), + OktaGroup.id.in_(app_groups_owned_subquery), + ) + ) + else: + query = query.filter(False) + + if "resolver_user_id" in search_args: + if search_args["resolver_user_id"] == "@me": + query = query.filter(RoleRequest.resolver_user_id == g.current_user_id) + else: + resolver_alias = aliased(OktaUser) + query = query.outerjoin(RoleRequest.resolver.of_type(resolver_alias)).filter( + db.or_( + RoleRequest.resolver_user_id == search_args["resolver_user_id"], + resolver_alias.email.ilike(search_args["resolver_user_id"]), + ) + ) + + # Implement basic search with the "q" url parameter + if "q" in search_args and len(search_args["q"]) > 0: + like_search = f"%{search_args['q']}%" + requester_alias = aliased(OktaUser) + resolver_alias = aliased(OktaUser) + role = aliased(OktaGroup) + group = aliased(OktaGroup) + query = ( + query.join(RoleRequest.requester.of_type(requester_alias)) + .join(RoleRequest.requester_role.of_type(role)) + .join(RoleRequest.requested_group.of_type(group)) + .outerjoin(RoleRequest.resolver.of_type(resolver_alias)) + .filter( + db.or_( + RoleRequest.id.like(f"{search_args['q']}%"), + cast(RoleRequest.status, String).ilike(like_search), + requester_alias.email.ilike(like_search), + requester_alias.first_name.ilike(like_search), + requester_alias.last_name.ilike(like_search), + requester_alias.display_name.ilike(like_search), + (requester_alias.first_name + " " + requester_alias.last_name).ilike(like_search), + role.name.ilike(like_search), + role.description.ilike(like_search), + group.name.ilike(like_search), + group.description.ilike(like_search), + resolver_alias.email.ilike(like_search), + resolver_alias.first_name.ilike(like_search), + resolver_alias.last_name.ilike(like_search), + resolver_alias.display_name.ilike(like_search), + (resolver_alias.first_name + " " + resolver_alias.last_name).ilike(like_search), + ) + ) + ) + + return paginate( + query, + RoleRequestSchema( + many=True, + only=( + "id", + "created_at", + "updated_at", + "resolved_at", + "status", + "request_ownership", + "requester.id", + "requester.email", + "requester.first_name", + "requester.last_name", + "requester.display_name", + "requester.deleted_at", + "requester_role.id", + "requester_role.name", + "requester_role.deleted_at", + "requested_group.id", + "requested_group.type", + "requested_group.name", + "requested_group.deleted_at", + "resolver.id", + "resolver.email", + "resolver.first_name", + "resolver.last_name", + "resolver.display_name", + ), + ), + ) + + @FlaskApiSpecDecorators.request_schema(CreateRoleRequestSchema) + @FlaskApiSpecDecorators.response_schema(RoleRequestSchema) + def post(self) -> ResponseReturnValue: + role_request_args = CreateRoleRequestSchema().load(request.get_json()) + requester_role = ( + db.session.query(RoleGroup) + .filter(RoleGroup.deleted_at.is_(None)) + .filter(RoleGroup.id == role_request_args["role_id"]) + .first_or_404() + ) + + # Ensure requester not deleted and owns the role group + if OktaUser.query.filter(OktaUser.deleted_at.is_(None)).filter( + OktaUser.id == g.current_user_id + ).first() is None or not AuthorizationHelpers.can_manage_group(requester_role): + abort(403, "Current user is not allowed to perform this action") + + group = ( + db.session.query(with_polymorphic(OktaGroup, [AppGroup])) + .filter(OktaGroup.deleted_at.is_(None)) + .filter(OktaGroup.id == role_request_args["group_id"]) + .first_or_404() + ) + + if not group.is_managed: + abort( + 400, + "Groups not managed by Access cannot be modified", + ) + + if type(group) is RoleGroup: + abort( + 400, + "Role requests may only be made for groups and app groups (not roles).", + ) + + existing_role_requests = ( + RoleRequest.query.filter(RoleRequest.requester_user_id == g.current_user_id) + .filter(RoleRequest.requester_role_id == role_request_args["role_id"]) + .filter(RoleRequest.requested_group_id == role_request_args["group_id"]) + .filter(RoleRequest.request_ownership == role_request_args["group_owner"]) + .filter(RoleRequest.status == AccessRequestStatus.PENDING) + .filter(RoleRequest.resolved_at.is_(None)) + .all() + ) + for existing_role_request in existing_role_requests: + RejectRoleRequest( + role_request=existing_role_request, + rejection_reason="Closed due to duplicate role request creation", + notify_requester=False, + current_user_id=g.current_user_id, + ).execute() + + role_request = CreateRoleRequest( + requester_user=g.current_user_id, + requester_role=role_request_args["role_id"], + requested_group=role_request_args["group_id"], + request_ownership=role_request_args["group_owner"], + request_reason=role_request_args.get("reason"), + request_ending_at=role_request_args.get("ending_at"), + ).execute() + + if role_request is None: + abort(400, "Groups not managed by Access cannot be modified") + + role_request = RoleRequest.query.options(DEFAULT_LOAD_OPTIONS).filter(RoleRequest.id == role_request.id).first() + return ( + RoleRequestSchema( + only=( + "id", + "created_at", + "updated_at", + "resolved_at", + "status", + "request_ownership", + "request_reason", + "request_ending_at", + "requester.id", + "requester.email", + "requester.first_name", + "requester.last_name", + "requester.display_name", + "requester.deleted_at", + "requester_role.id", + "requester_role.name", + "requester_role.deleted_at", + "requested_group.id", + "requested_group.type", + "requested_group.name", + "requested_group.deleted_at", + ), + ).dump(role_request), + 201, + ) diff --git a/api/views/role_requests_views.py b/api/views/role_requests_views.py new file mode 100644 index 00000000..269248c1 --- /dev/null +++ b/api/views/role_requests_views.py @@ -0,0 +1,22 @@ +from flask import Blueprint + +from api.extensions import Api, docs +from api.views.resources import RoleRequestList, RoleRequestResource + +bp_name = "api-role-requests" +bp_url_prefix = "/api/role-requests" +bp = Blueprint(bp_name, __name__, url_prefix=bp_url_prefix) + +api = Api(bp) + +api.add_resource( + RoleRequestResource, + "/", + endpoint="role_request_by_id", +) +api.add_resource(RoleRequestList, "", endpoint="role_requests") + + +def register_docs() -> None: + docs.register(RoleRequestResource, blueprint=bp_name, endpoint="role_request_by_id") + docs.register(RoleRequestList, blueprint=bp_name, endpoint="role_requests") diff --git a/api/views/schemas/__init__.py b/api/views/schemas/__init__.py index 1f93c098..33a69502 100644 --- a/api/views/schemas/__init__.py +++ b/api/views/schemas/__init__.py @@ -15,6 +15,7 @@ PolymorphicGroupSchema, RoleGroupMapSchema, RoleGroupSchema, + RoleRequestSchema, TagSchema, ) from api.views.schemas.delete_message import DeleteMessageSchema @@ -26,17 +27,20 @@ GroupPaginationSchema, GroupRoleAuditPaginationSchema, RolePaginationSchema, + RoleRequestPaginationSchema, SearchAccessRequestPaginationRequestSchema, SearchAuditPaginationRequestSchema, SearchGroupPaginationRequestSchema, SearchGroupRoleAuditPaginationRequestSchema, SearchPaginationRequestSchema, + SearchRoleRequestPaginationRequestSchema, SearchUserGroupAuditPaginationRequestSchema, TagPaginationSchema, UserGroupAuditPaginationSchema, UserPaginationSchema, ) from api.views.schemas.role_memberships import RoleMemberSchema +from api.views.schemas.role_requests import CreateRoleRequestSchema, ResolveRoleRequestSchema __all__ = [ "AccessRequestPaginationSchema", @@ -48,6 +52,7 @@ "AuditLogSchema", "AuditOrderBy", "CreateAccessRequestSchema", + "CreateRoleRequestSchema", "DeleteMessageSchema", "EventType", "GroupMemberSchema", @@ -59,15 +64,19 @@ "OktaUserSchema", "PolymorphicGroupSchema", "ResolveAccessRequestSchema", + "ResolveRoleRequestSchema", "RoleGroupMapSchema", "RoleGroupSchema", "RoleMemberSchema", "RolePaginationSchema", + "RoleRequestPaginationSchema", + "RoleRequestSchema", "SearchAccessRequestPaginationRequestSchema", "SearchAuditPaginationRequestSchema", "SearchGroupPaginationRequestSchema", "SearchGroupRoleAuditPaginationRequestSchema", "SearchPaginationRequestSchema", + "SearchRoleRequestPaginationRequestSchema", "SearchUserGroupAuditPaginationRequestSchema", "TagPaginationSchema", "TagSchema", diff --git a/api/views/schemas/audit_logs.py b/api/views/schemas/audit_logs.py index a172c495..5cebbb77 100644 --- a/api/views/schemas/audit_logs.py +++ b/api/views/schemas/audit_logs.py @@ -9,6 +9,7 @@ OktaUserSchema, PolymorphicGroupSchema, RoleGroupSchema, + RoleRequestSchema, TagSchema, ) @@ -28,6 +29,9 @@ class EventType(Enum): group_modify_tags = "GROUP_MODIFY_TAG" group_modify_users = "GROUP_MODIFY_USER" role_group_modify = "ROLE_GROUP_MODIFY" + role_request_approve = "ROLE_REQUEST_APPROVE" + role_request_create = "ROLE_REQUEST_CREATE" + role_request_reject = "ROLE_REQUEST_REJECT" tag_create = "TAG_CREATE" tag_modify = "TAG_MODIFY" tag_delete = "TAG_DELETE" @@ -67,6 +71,19 @@ class AuditLogSchema(Schema): "approval_ending_at", ), ) + role_request = fields.Nested( + RoleRequestSchema, + only=( + "id", + "requester_role.id", + "requester_role.name", + "request_reason", + "request_ending_at", + "request_ownership", + "resolution_reason", + "approval_ending_at", + ), + ) requester = fields.Nested(OktaUserSchema, only=("id", "email")) app = fields.Nested(AppSchema, only=("id", "name")) diff --git a/api/views/schemas/core_schemas.py b/api/views/schemas/core_schemas.py index 837b0c3d..20a08959 100644 --- a/api/views/schemas/core_schemas.py +++ b/api/views/schemas/core_schemas.py @@ -18,6 +18,7 @@ OktaUserGroupMember, RoleGroup, RoleGroupMap, + RoleRequest, Tag, ) @@ -1382,6 +1383,77 @@ class Meta: ) +class RoleRequestSchema(SQLAlchemyAutoSchema): + requester = fields.Nested(lambda: OktaUserSchema) + active_requester = fields.Nested(lambda: OktaUserSchema) + requester_role = fields.Nested(lambda: RoleGroupSchema) + active_requester_role = fields.Nested(lambda: RoleGroupSchema) + requested_group = fields.Nested(lambda: PolymorphicGroupSchema) + active_requested_group = fields.Nested(lambda: PolymorphicGroupSchema) + + resolver = fields.Nested(lambda: OktaUserSchema) + active_resolver = fields.Nested(lambda: OktaUserSchema) + + approved_membership = fields.Nested( + lambda: RoleGroupMapSchema( + only=( + "is_owner", + "ended_at", + ) + ), + ) + + class Meta: + model = RoleRequest + sqla_session = db.session + load_instance = True + include_relationships = True + fields = ( + "id", + "created_at", + "updated_at", + "resolved_at", + "status", + "requester", + "active_requester", + "requester_role", + "active_requester_role", + "requested_group", + "requested_group.app", + "active_requested_group", + "request_ownership", + "request_reason", + "request_ending_at", + "resolver", + "active_resolver", + "resolution_reason", + "approved_membership", + "approval_ending_at", + ) + dump_only = ( + "id", + "created_at", + "updated_at", + "resolved_at", + "status", + "requester", + "active_requester", + "requester_role", + "active_requester_role", + "requested_group", + "requested_group.app", + "active_requested_group", + "request_ownership", + "request_reason", + "request_ending_at", + "resolver", + "active_resolver", + "resolution_reason", + "approved_membership", + "approval_ending_at", + ) + + class TagSchema(SQLAlchemyAutoSchema): name = auto_field( required=True, diff --git a/api/views/schemas/pagination.py b/api/views/schemas/pagination.py index c2c1eafa..15bf91d0 100644 --- a/api/views/schemas/pagination.py +++ b/api/views/schemas/pagination.py @@ -11,6 +11,7 @@ PolymorphicGroupSchema, RoleGroupMapSchema, RoleGroupSchema, + RoleRequestSchema, TagSchema, ) @@ -66,6 +67,15 @@ class SearchAccessRequestPaginationRequestSchema(SearchPaginationRequestSchema): resolver_user_id = fields.String(load_only=True) +class SearchRoleRequestPaginationRequestSchema(SearchPaginationRequestSchema): + status = fields.Enum(AccessRequestStatus, load_only=True) + requester_user_id = fields.String(load_only=True) + requester_role_id = fields.String(load_only=True) + requested_group_id = fields.String(load_only=True) + assignee_user_id = fields.String(load_only=True) + resolver_user_id = fields.String(load_only=True) + + class PaginationResponseSchema(Schema): total = fields.Int(dump_only=True) pages = fields.Int(dump_only=True) @@ -77,6 +87,10 @@ class AccessRequestPaginationSchema(PaginationResponseSchema): results = fields.Nested(lambda: AccessRequestSchema(many=True), dump_only=True) +class RoleRequestPaginationSchema(PaginationResponseSchema): + results = fields.Nested(lambda: RoleRequestSchema(many=True), dump_only=True) + + class AppPaginationSchema(PaginationResponseSchema): results = fields.Nested(lambda: AppSchema(many=True), dump_only=True) diff --git a/api/views/schemas/role_requests.py b/api/views/schemas/role_requests.py new file mode 100644 index 00000000..c471112f --- /dev/null +++ b/api/views/schemas/role_requests.py @@ -0,0 +1,52 @@ +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from marshmallow import Schema, ValidationError, fields, post_load, validate + + +class CreateRoleRequestSchema(Schema): + role_id = fields.String(validate=validate.Length(equal=20), required=True, load_only=True) + group_id = fields.String(validate=validate.Length(equal=20), required=True, load_only=True) + group_owner = fields.Boolean(load_default=False, load_only=True) + reason = fields.String(validate=validate.Length(max=1024), load_only=True) + + @staticmethod + def must_be_in_the_future(data: Optional[datetime]) -> None: + if data and data < datetime.now(): + raise ValidationError("Ended at datetime for access request approval must be in the future") + + ending_at = fields.DateTime( + load_only=True, + format="rfc822", + metadata={"validation": must_be_in_the_future}, + ) + + @post_load + def convert_to_utc(self, item: Dict[str, Any], many: bool, **kwargs: Any) -> Dict[str, Any]: + # Ensure the datetime we store in the database is UTC + if "ending_at" in item: + item["ending_at"] = item["ending_at"].astimezone(tz=timezone.utc) + return item + + +class ResolveRoleRequestSchema(Schema): + approved = fields.Boolean(required=True, load_only=True) + reason = fields.String(load_only=True, validate=validate.Length(max=1024)) + + @staticmethod + def must_be_in_the_future(data: Optional[datetime]) -> None: + if data and data < datetime.now(): + raise ValidationError("Ended at datetime for access request approval must be in the future") + + ending_at = fields.DateTime( + load_only=True, + format="rfc822", + metadata={"validation": must_be_in_the_future}, + ) + + @post_load + def convert_to_utc(self, item: Dict[str, Any], many: bool, **kwargs: Any) -> Dict[str, Any]: + # Ensure the datetime we store in the database is UTC + if "ending_at" in item: + item["ending_at"] = item["ending_at"].astimezone(tz=timezone.utc) + return item diff --git a/migrations/versions/6d2a03b326f9_role_requests.py b/migrations/versions/6d2a03b326f9_role_requests.py new file mode 100644 index 00000000..e2e1df59 --- /dev/null +++ b/migrations/versions/6d2a03b326f9_role_requests.py @@ -0,0 +1,103 @@ +"""Role requests + +Revision ID: 6d2a03b326f9 +Revises: d6db40b0805d +Create Date: 2024-11-01 15:57:24.776719 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "6d2a03b326f9" +down_revision = "d6db40b0805d" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "role_request", + sa.Column("id", sa.Unicode(length=20), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("resolved_at", sa.DateTime(), nullable=True), + sa.Column("status", sa.Enum("PENDING", "APPROVED", "REJECTED", name="accessrequeststatus"), nullable=False), + sa.Column("requester_user_id", sa.Unicode(length=50), nullable=False), + sa.Column("requester_role_id", sa.Unicode(length=50), nullable=False), + sa.Column("requested_group_id", sa.Unicode(length=50), nullable=False), + sa.Column("request_ownership", sa.Boolean(), nullable=False), + sa.Column("request_reason", sa.Unicode(length=1024), nullable=False), + sa.Column("request_ending_at", sa.DateTime(), nullable=True), + sa.Column("resolver_user_id", sa.Unicode(length=50), nullable=True), + sa.Column("resolution_reason", sa.Unicode(length=1024), nullable=False), + sa.Column("approval_ending_at", sa.DateTime(), nullable=True), + sa.Column("approved_membership_id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=True), + sa.ForeignKeyConstraint( + ["approved_membership_id"], + ["role_group_map.id"], + name=op.f("fk_role_request_approved_membership_id_role_group_map"), + ), + sa.ForeignKeyConstraint( + ["requested_group_id"], ["okta_group.id"], name=op.f("fk_role_request_requested_group_id_okta_group") + ), + sa.ForeignKeyConstraint( + ["requester_role_id"], ["okta_group.id"], name=op.f("fk_role_request_requester_role_id_okta_group") + ), + sa.ForeignKeyConstraint( + ["requester_user_id"], ["okta_user.id"], name=op.f("fk_role_request_requester_user_id_okta_user") + ), + sa.ForeignKeyConstraint( + ["resolver_user_id"], ["okta_user.id"], name=op.f("fk_role_request_resolver_user_id_okta_user") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_role_request")), + ) + with op.batch_alter_table("access_request", schema=None) as batch_op: + batch_op.alter_column("requester_user_id", existing_type=sa.VARCHAR(), nullable=False) + batch_op.alter_column("requested_group_id", existing_type=sa.VARCHAR(), nullable=False) + + with op.batch_alter_table("app_tag_map", schema=None) as batch_op: + batch_op.alter_column("tag_id", existing_type=sa.VARCHAR(), nullable=False) + batch_op.alter_column("app_id", existing_type=sa.VARCHAR(), nullable=False) + + with op.batch_alter_table("okta_group_tag_map", schema=None) as batch_op: + batch_op.alter_column("tag_id", existing_type=sa.VARCHAR(), nullable=False) + batch_op.alter_column("group_id", existing_type=sa.VARCHAR(), nullable=False) + + with op.batch_alter_table("okta_user_group_member", schema=None) as batch_op: + batch_op.alter_column("user_id", existing_type=sa.VARCHAR(), nullable=False) + batch_op.alter_column("group_id", existing_type=sa.VARCHAR(), nullable=False) + + with op.batch_alter_table("role_group_map", schema=None) as batch_op: + batch_op.alter_column("role_id", existing_type=sa.VARCHAR(), nullable=False) + batch_op.alter_column("group_id", existing_type=sa.VARCHAR(), nullable=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("role_group_map", schema=None) as batch_op: + batch_op.alter_column("group_id", existing_type=sa.VARCHAR(), nullable=True) + batch_op.alter_column("role_id", existing_type=sa.VARCHAR(), nullable=True) + + with op.batch_alter_table("okta_user_group_member", schema=None) as batch_op: + batch_op.alter_column("group_id", existing_type=sa.VARCHAR(), nullable=True) + batch_op.alter_column("user_id", existing_type=sa.VARCHAR(), nullable=True) + + with op.batch_alter_table("okta_group_tag_map", schema=None) as batch_op: + batch_op.alter_column("group_id", existing_type=sa.VARCHAR(), nullable=True) + batch_op.alter_column("tag_id", existing_type=sa.VARCHAR(), nullable=True) + + with op.batch_alter_table("app_tag_map", schema=None) as batch_op: + batch_op.alter_column("app_id", existing_type=sa.VARCHAR(), nullable=True) + batch_op.alter_column("tag_id", existing_type=sa.VARCHAR(), nullable=True) + + with op.batch_alter_table("access_request", schema=None) as batch_op: + batch_op.alter_column("requested_group_id", existing_type=sa.VARCHAR(), nullable=True) + batch_op.alter_column("requester_user_id", existing_type=sa.VARCHAR(), nullable=True) + + op.drop_table("role_request") + # ### end Alembic commands ### diff --git a/tests/conftest.py b/tests/conftest.py index 055758e3..f4575a6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ OktaGroupFactory, OktaUserFactory, RoleGroupFactory, + RoleRequestFactory, TagFactory, ) @@ -25,6 +26,7 @@ register(AppGroupFactory, "app_group") register(AppFactory, "access_app") register(AccessRequestFactory, "access_request") +register(RoleRequestFactory, "role_request") register(TagFactory, "tag") diff --git a/tests/factories.py b/tests/factories.py index a0f3d47b..13f9b077 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -11,7 +11,17 @@ from okta.models.user_schema import UserSchema from sqlalchemy.orm import joinedload -from api.models import AccessRequest, AccessRequestStatus, App, AppGroup, OktaGroup, OktaUser, RoleGroup, Tag +from api.models import ( + AccessRequest, + AccessRequestStatus, + App, + AppGroup, + OktaGroup, + OktaUser, + RoleGroup, + RoleRequest, + Tag, +) class GroupProfileFactory(factory.Factory): @@ -231,6 +241,15 @@ class Meta: model = AccessRequest +class RoleRequestFactory(factory.Factory): + id = factory.Faker("pystr") + status = AccessRequestStatus.PENDING + request_reason = factory.Faker("paragraph", nb_sentences=5) + + class Meta: + model = RoleRequest + + class TagFactory(factory.Factory): id = factory.Faker("pystr") name = fuzzy.FuzzyText(length=12, prefix="Tag-", chars=string.ascii_letters + string.digits + "-") diff --git a/tests/test_role_request.py b/tests/test_role_request.py new file mode 100644 index 00000000..aa89a1f8 --- /dev/null +++ b/tests/test_role_request.py @@ -0,0 +1,1047 @@ +from datetime import datetime, timedelta +from typing import Any + +from flask import Flask, url_for +from flask.testing import FlaskClient +from flask_sqlalchemy import SQLAlchemy +from pytest_mock import MockerFixture + +from api.models import ( + AccessRequestStatus, + App, + AppGroup, + OktaGroup, + OktaGroupTagMap, + OktaUser, + OktaUserGroupMember, + RoleGroup, + RoleGroupMap, + RoleRequest, + Tag, +) +from api.models.access_request import get_all_possible_request_approvers +from api.operations import ( + ApproveRoleRequest, + CreateRoleRequest, + ModifyGroupUsers, + ModifyRoleGroups, + RejectRoleRequest, +) +from api.plugins import ConditionalAccessResponse, get_conditional_access_hook, get_notification_hook +from api.services import okta +from tests.factories import AppGroupFactory, OktaGroupFactory, OktaUserFactory, RoleGroupFactory, RoleRequestFactory + +SEVEN_DAYS_IN_SECONDS = 7 * 24 * 60 * 60 +THREE_DAYS_IN_SECONDS = 3 * 24 * 60 * 60 +ONE_DAY_IN_SECONDS = 24 * 60 * 60 + + +def test_get_role_request( + app: Flask, + client: FlaskClient, + db: SQLAlchemy, + mocker: MockerFixture, + okta_group: OktaGroup, + role_group: RoleGroup, + user: OktaUser, +) -> None: + # test 404 + role_request_url = url_for("api-role-requests.role_request_by_id", role_request_id="randomid") + rep = client.get(role_request_url) + assert rep.status_code == 404 + + role_group2 = RoleGroupFactory.create() + + db.session.add(user) + db.session.add(okta_group) + db.session.add(role_group) + db.session.add(role_group2) + db.session.commit() + + ModifyGroupUsers(group=role_group, members_to_add=[user.id], owners_to_add=[user.id], sync_to_okta=False).execute() + + # should be OK + okta_group_role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=okta_group, + request_ownership=False, + request_reason="test reason", + ).execute() + assert okta_group_role_request is not None + + # should not be allowed + role_group_role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=role_group2, + request_ownership=False, + request_reason="test reason", + ).execute() + assert role_group_role_request is None + + # test get okta group role_request + role_request_url = url_for("api-role-requests.role_request_by_id", role_request_id=okta_group_role_request.id) + rep = client.get(role_request_url) + assert rep.status_code == 200 + + data = rep.get_json() + assert data["requester"]["email"] == user.email + assert data["requester_role"]["name"] == role_group.name + assert data["requested_group"]["name"] == okta_group.name + assert data["status"] == okta_group_role_request.status + assert data["request_reason"] == okta_group_role_request.request_reason + assert data["request_ownership"] == okta_group_role_request.request_ownership + + role_url = url_for("api-roles.role_members_by_id", role_id=role_group.id) + rep = client.get(role_url) + assert rep.status_code == 200 + + data = rep.get_json() + assert len(data["groups_in_role"]) == 0 + assert len(data["groups_owned_by_role"]) == 0 + + access_owner = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + add_user_to_group_spy = mocker.patch.object(okta, "async_add_user_to_group") + add_owner_to_group_spy = mocker.patch.object(okta, "async_add_owner_to_group") + + okta_group_role_request = ApproveRoleRequest( + role_request=okta_group_role_request, approver_user=access_owner + ).execute() + + assert add_user_to_group_spy.call_count == 1 + assert add_owner_to_group_spy.call_count == 0 + + rep = client.get(role_request_url) + assert rep.status_code == 200 + + data = rep.get_json() + assert data["requester"]["email"] == user.email + assert data["requested_group"]["name"] == okta_group.name + assert data["status"] == okta_group_role_request.status + assert data["request_reason"] == okta_group_role_request.request_reason + assert data["request_ownership"] == okta_group_role_request.request_ownership + + role_url = url_for("api-roles.role_members_by_id", role_id=role_group.id) + rep = client.get(role_url) + assert rep.status_code == 200 + + data = rep.get_json() + assert len(data["groups_in_role"]) == 1 + assert data["groups_in_role"][0] == okta_group.id + assert len(data["groups_owned_by_role"]) == 0 + + +def test_put_role_request( + app: Flask, + client: FlaskClient, + db: SQLAlchemy, + mocker: MockerFixture, + role_request: RoleRequest, + okta_group: OktaGroup, + role_group: RoleGroup, + user: OktaUser, +) -> None: + # test 404 + role_request_url = url_for("api-role-requests.role_request_by_id", role_request_id="randomid") + rep = client.put(role_request_url) + assert rep.status_code == 404 + + db.session.add(user) + db.session.add(okta_group) + db.session.add(role_group) + db.session.commit() + + ModifyGroupUsers(group=role_group, members_to_add=[user.id], owners_to_add=[user.id], sync_to_okta=False).execute() + + role_request.requested_group_id = okta_group.id + role_request.requester_role_id = role_group.id + role_request.requester_user_id = user.id + db.session.add(role_request) + db.session.commit() + + # test missing data + data: dict[str, Any] = {} + role_request_url = url_for("api-role-requests.role_request_by_id", role_request_id=role_request.id) + rep = client.put(role_request_url, json=data) + assert rep.status_code == 400 + + # test update role_request + add_user_to_group_spy = mocker.patch.object(okta, "async_add_user_to_group") + add_owner_to_group_spy = mocker.patch.object(okta, "async_add_owner_to_group") + + assert RoleGroupMap.query.filter(RoleGroupMap.ended_at.is_(None)).count() == 0 + # The Access owner plus the role membership and ownership added above + assert OktaUserGroupMember.query.filter(OktaUserGroupMember.ended_at.is_(None)).count() == 3 + + data = {"approved": True, "reason": "test reason"} + + rep = client.put(role_request_url, json=data) + assert rep.status_code == 200 + assert add_user_to_group_spy.call_count == 1 + assert add_owner_to_group_spy.call_count == 0 + + access_owner = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + data = rep.get_json() + assert data["requester"]["email"] == user.email + assert data["requester_role"]["name"] == role_group.name + assert data["requested_group"]["name"] == okta_group.name + assert role_request.status == AccessRequestStatus.APPROVED + assert data["status"] == role_request.status + assert data["request_reason"] == role_request.request_reason + assert data["request_ownership"] == role_request.request_ownership + assert data["resolver"]["email"] == access_owner.email + assert data["resolution_reason"] == role_request.resolution_reason + + assert RoleGroupMap.query.filter(RoleGroupMap.ended_at.is_(None)).count() == 1 + assert OktaUserGroupMember.query.filter(OktaUserGroupMember.ended_at.is_(None)).count() == 4 + + role_request2 = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=okta_group, + request_ownership=True, + request_reason="test reason", + ).execute() + assert role_request2 is not None + role_request = role_request2 + + add_user_to_group_spy.reset_mock() + add_owner_to_group_spy.reset_mock() + data = {"approved": False, "reason": "test reason"} + + role_request_url = url_for("api-role-requests.role_request_by_id", role_request_id=role_request.id) + rep = client.put(role_request_url, json=data) + assert rep.status_code == 200 + assert add_user_to_group_spy.call_count == 0 + assert add_owner_to_group_spy.call_count == 0 + + access_owner = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + data = rep.get_json() + assert data["requester"]["email"] == user.email + assert data["requester_role"]["name"] == role_group.name + assert data["requested_group"]["name"] == okta_group.name + assert role_request.status == AccessRequestStatus.REJECTED + assert data["status"] == role_request.status + assert data["request_reason"] == role_request.request_reason + assert data["request_ownership"] == role_request.request_ownership + assert data["resolver"]["email"] == access_owner.email + assert data["resolution_reason"] == role_request.resolution_reason + + assert RoleGroupMap.query.filter(RoleGroupMap.ended_at.is_(None)).count() == 1 + assert OktaUserGroupMember.query.filter(OktaUserGroupMember.ended_at.is_(None)).count() == 4 + + +def test_put_role_request_by_non_owner( + client: FlaskClient, app: Flask, db: SQLAlchemy, role_group: RoleGroup, okta_group: OktaGroup, user: OktaUser +) -> None: + access_owner = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + db.session.add(user) + db.session.add(role_group) + db.session.add(okta_group) + db.session.commit() + + ModifyGroupUsers( + group=role_group, + members_to_add=[user.id], + owners_to_add=[access_owner.id, user.id], + sync_to_okta=False, + ).execute() + + role_request_by_owner = CreateRoleRequest( + requester_user=access_owner, + requester_role=role_group, + requested_group=okta_group, + request_ownership=False, + request_reason="test reason", + ).execute() + + role_request_by_non_owner = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=okta_group, + request_ownership=False, + request_reason="test reason", + ).execute() + + db.session.commit() + + assert OktaUserGroupMember.query.filter(OktaUserGroupMember.ended_at.is_(None)).count() == 4 + + assert role_request_by_owner is not None + assert role_request_by_owner.status == AccessRequestStatus.PENDING + assert role_request_by_non_owner is not None + assert role_request_by_non_owner.status == AccessRequestStatus.PENDING + + data: dict[str, Any] = {} + app.config["CURRENT_OKTA_USER_EMAIL"] = user.email + + role_request_url = url_for("api-role-requests.role_request_by_id", role_request_id=role_request_by_owner.id) + data = {"approved": True, "reason": "test approval"} + rep = client.put(role_request_url, json=data) + assert rep.status_code == 403 + + assert role_request_by_owner.status == AccessRequestStatus.PENDING + assert role_request_by_owner.resolved_at is None + assert role_request_by_owner.resolver_user_id is None + + assert OktaUserGroupMember.query.filter(OktaUserGroupMember.ended_at.is_(None)).count() == 4 + + data = {"approved": False, "reason": "test rejection"} + + rep = client.put(role_request_url, json=data) + assert rep.status_code == 403 + + assert role_request_by_owner.status == AccessRequestStatus.PENDING + assert role_request_by_owner.resolved_at is None + assert role_request_by_owner.resolver_user_id is None + + assert OktaUserGroupMember.query.filter(OktaUserGroupMember.ended_at.is_(None)).count() == 4 + + role_request_url = url_for("api-role-requests.role_request_by_id", role_request_id=role_request_by_non_owner.id) + + data = {"approved": True, "reason": "test approval"} + rep = client.put(role_request_url, json=data) + assert rep.status_code == 403 + + assert role_request_by_non_owner.status == AccessRequestStatus.PENDING + assert role_request_by_non_owner.resolved_at is None + assert role_request_by_non_owner.resolver_user_id is None + + assert OktaUserGroupMember.query.filter(OktaUserGroupMember.ended_at.is_(None)).count() == 4 + + data = {"approved": False, "reason": "test rejection"} + + rep = client.put(role_request_url, json=data) + assert rep.status_code == 200 + + data = rep.get_json() + assert data["requester"]["email"] == user.email + assert data["requester_role"]["name"] == role_group.name + assert data["requested_group"]["name"] == okta_group.name + assert data["status"] == AccessRequestStatus.REJECTED + assert data["request_reason"] == role_request_by_non_owner.request_reason + assert data["request_ownership"] == role_request_by_non_owner.request_ownership + assert data["resolver"]["email"] == user.email + assert data["resolution_reason"] == role_request_by_non_owner.resolution_reason + + assert role_request_by_non_owner.resolved_at is not None + assert role_request_by_non_owner.resolver_user_id == user.id + + assert OktaUserGroupMember.query.filter(OktaUserGroupMember.ended_at.is_(None)).count() == 4 + + +def test_create_role_request( + app: Flask, client: FlaskClient, db: SQLAlchemy, role_group: RoleGroup, okta_group: OktaGroup +) -> None: + # test bad data + role_requests_url = url_for("api-role-requests.role_requests") + data: dict[str, Any] = {} + rep = client.post(role_requests_url, json=data) + assert rep.status_code == 400 + + db.session.add(okta_group) + db.session.add(role_group) + db.session.commit() + + data = { + "role_id": role_group.id, + "group_id": okta_group.id, + "group_owner": False, + "reason": "test reason", + } + + rep = client.post(role_requests_url, json=data) + assert rep.status_code == 201 + + data = rep.get_json() + role_request = db.session.get(RoleRequest, data["id"]) + access_owner = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + assert data["requester"]["email"] == access_owner.email + assert data["requester_role"]["name"] == role_group.name + assert data["requested_group"]["name"] == okta_group.name + assert role_request.status == AccessRequestStatus.PENDING + assert data["status"] == role_request.status + assert data["request_reason"] == role_request.request_reason + assert data["request_ownership"] == role_request.request_ownership + + +# Try to create an role request when not the role owner or Access admin, then become owner and try again +def test_create_role_request_not_role_owner( + app: Flask, client: FlaskClient, db: SQLAlchemy, role_group: RoleGroup, okta_group: OktaGroup, user: OktaUser +) -> None: + db.session.add(user) + db.session.add(okta_group) + db.session.add(role_group) + db.session.commit() + + app.config["CURRENT_OKTA_USER_EMAIL"] = user.email + + data = { + "role_id": role_group.id, + "group_id": okta_group.id, + "group_owner": False, + "reason": "test reason", + } + + role_requests_url = url_for("api-role-requests.role_requests") + rep = client.post(role_requests_url, json=data) + assert rep.status_code == 403 + + ModifyGroupUsers( + group=role_group, + members_to_add=[], + owners_to_add=[user.id], + sync_to_okta=False, + ).execute() + + data = { + "role_id": role_group.id, + "group_id": okta_group.id, + "group_owner": False, + "reason": "test reason", + } + + rep = client.post(role_requests_url, json=data) + assert rep.status_code == 201 + + out = rep.get_json() + role_request = db.session.get(RoleRequest, out["id"]) + + assert out["requester"]["email"] == user.email + assert out["requester_role"]["name"] == role_group.name + assert out["requested_group"]["name"] == okta_group.name + assert role_request.status == AccessRequestStatus.PENDING + assert out["status"] == role_request.status + assert out["request_reason"] == role_request.request_reason + assert out["request_ownership"] == role_request.request_ownership + + +def test_get_all_role_request( + client: FlaskClient, + db: SQLAlchemy, + role_group: RoleGroup, + okta_group: OktaGroup, + user: OktaUser, +) -> None: + role_requests_url = url_for("api-role-requests.role_requests") + db.session.add(user) + db.session.add(role_group) + db.session.add(okta_group) + db.session.commit() + + role_requests = RoleRequestFactory.create_batch( + 10, requester_user_id=user.id, requester_role_id=role_group.id, requested_group_id=okta_group.id + ) + db.session.add_all(role_requests) + db.session.commit() + + rep = client.get(role_requests_url) + assert rep.status_code == 200 + + results = rep.get_json() + for request in role_requests: + assert any(u["id"] == request.id for u in results["results"]) + + rep = client.get(role_requests_url, query_string={"q": "pend"}) + assert rep.status_code == 200 + + results = rep.get_json() + for request in role_requests: + assert any(u["id"] == request.id for u in results["results"]) + + # Should be able to query by requester role and requested group + rep = client.get(role_requests_url, query_string={"q": role_requests[0].requester_role.name}) + assert rep.status_code == 200 + + results = rep.get_json() + assert any(u["id"] == role_requests[0].id for u in results["results"]) + + rep = client.get(role_requests_url, query_string={"q": role_requests[0].requested_group.name}) + assert rep.status_code == 200 + + results = rep.get_json() + assert any(u["id"] == role_requests[0].id for u in results["results"]) + + +def test_create_role_request_notification( + app: Flask, db: SQLAlchemy, role_group: RoleGroup, okta_group: OktaGroup, user: OktaUser, mocker: MockerFixture +) -> None: + db.session.add(user) + db.session.add(role_group) + db.session.add(okta_group) + db.session.commit() + + ModifyGroupUsers(group=role_group, members_to_add=[user.id], owners_to_add=[user.id], sync_to_okta=False).execute() + + hook = get_notification_hook() + request_created_notification_spy = mocker.patch.object(hook, "access_role_request_created") + request_completed_notification_spy = mocker.patch.object(hook, "access_role_request_completed") + add_membership_spy = mocker.patch.object(okta, "async_add_user_to_group") + + role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=okta_group, + request_ownership=False, + request_reason="test reason", + ).execute() + + assert role_request is not None + assert request_created_notification_spy.call_count == 1 + + access_owner = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + ApproveRoleRequest(role_request=role_request, approver_user=access_owner).execute() + + assert add_membership_spy.call_count == 1 + assert request_completed_notification_spy.call_count == 1 + + role_request.status = AccessRequestStatus.PENDING + role_request.resolved_at = None + db.session.commit() + + add_membership_spy.reset_mock() + request_completed_notification_spy.reset_mock() + + RejectRoleRequest(role_request=role_request, current_user_id=access_owner).execute() + + assert add_membership_spy.call_count == 0 + assert request_completed_notification_spy.call_count == 1 + + +def test_create_app_role_request_notification( + app: Flask, + db: SQLAlchemy, + access_app: App, + app_group: AppGroup, + role_group: RoleGroup, + user: OktaUser, + mocker: MockerFixture, +) -> None: + # test bad data + app_owner_user = OktaUserFactory.create() + app_owner_group = AppGroupFactory.create() + + # Add App + db.session.add(access_app) + + # Add Users + db.session.add(app_owner_user) + db.session.add(user) + + db.session.commit() + + # Add app group that no one owns + app_group.app_id = access_app.id + app_group.is_owner = False + db.session.add(app_group) + + # Add app owners group + app_owner_group.app_id = access_app.id + app_owner_group.is_owner = True + db.session.add(app_owner_group) + + db.session.commit() + + # Add role group that user owns + db.session.add(role_group) + db.session.commit() + + ModifyGroupUsers(group=role_group, members_to_add=[user.id], owners_to_add=[user.id], sync_to_okta=False).execute() + + # Add app_owner_user to the owner group + ModifyGroupUsers( + group=app_owner_group, members_to_add=[], owners_to_add=[app_owner_user.id], sync_to_okta=False + ).execute() + + hook = get_notification_hook() + request_created_notification_spy = mocker.patch.object(hook, "access_role_request_created") + request_completed_notification_spy = mocker.patch.object(hook, "access_role_request_completed") + add_membership_spy = mocker.patch.object(okta, "async_add_user_to_group") + + role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=app_group, + request_ownership=False, + request_reason="test reason", + ).execute() + + assert role_request is not None + assert request_created_notification_spy.call_count == 1 + _, kwargs = request_created_notification_spy.call_args + assert kwargs["role_request"] == role_request + assert kwargs["role"] == role_group + assert kwargs["group"] == app_group + assert kwargs["requester"] == user + + access_owner = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + ApproveRoleRequest(role_request=role_request, approver_user=access_owner).execute() + + assert add_membership_spy.call_count == 1 + assert request_completed_notification_spy.call_count == 1 + _, kwargs = request_completed_notification_spy.call_args + assert kwargs["role_request"] == role_request + assert kwargs["role"] == role_group + assert kwargs["group"] == app_group + assert kwargs["requester"] == user + + role_request.status = AccessRequestStatus.PENDING + role_request.resolved_at = None + db.session.commit() + + add_membership_spy.reset_mock() + request_completed_notification_spy.reset_mock() + + RejectRoleRequest(role_request=role_request, current_user_id=access_owner).execute() + + assert add_membership_spy.call_count == 0 + assert request_completed_notification_spy.call_count == 1 + _, kwargs = request_completed_notification_spy.call_args + assert kwargs["role_request"] == role_request + assert kwargs["role"] == role_group + assert kwargs["group"] == app_group + assert kwargs["requester"] == user + + +def test_get_all_possible_role_request_approvers(app: Flask, mocker: MockerFixture, db: SQLAlchemy) -> None: + access_admin = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + users = OktaUserFactory.build_batch(3) + db.session.add_all(users) + db.session.commit() + + mocker.patch( + "api.models.access_request.get_group_managers", + return_value=[users[0], users[1]], + ) + + mocker.patch( + "api.models.access_request.get_app_managers", + return_value=[users[0], users[2]], + ) + + req = RoleRequest() + req.requested_group = AppGroupFactory.create() + + approvers = get_all_possible_request_approvers(req) + + # Assert that the access admin and 3 users are returned with no duplicates + assert len(approvers) == 4 + assert access_admin in approvers + assert users[0] in approvers + assert users[1] in approvers + assert users[2] in approvers + + +def test_resolve_app_role_request_notification( + app: Flask, + db: SQLAlchemy, + access_app: App, + app_group: AppGroup, + role_group: RoleGroup, + user: OktaUser, + mocker: MockerFixture, +) -> None: + access_admin = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + app_owner_user1 = OktaUserFactory.build() + app_owner_user2 = OktaUserFactory.build() + app_owner_group = AppGroupFactory.build() + + # Add App + db.session.add(access_app) + + # Add Users + db.session.add(app_owner_user1) + db.session.add(app_owner_user2) + db.session.add(user) # Future group owner + + db.session.commit() + + # Add app group that no one owns + app_group.app_id = access_app.id + app_group.is_owner = False + db.session.add(app_group) + + # Add app owners group + app_owner_group.app_id = access_app.id + app_owner_group.is_owner = True + db.session.add(app_owner_group) + + db.session.commit() + + # Add role group that user owns + db.session.add(role_group) + db.session.commit() + + ModifyGroupUsers(group=role_group, members_to_add=[user.id], owners_to_add=[user.id], sync_to_okta=False).execute() + + # Add app_owner_user to the owner group + ModifyGroupUsers( + group=app_owner_group, + members_to_add=[], + owners_to_add=[app_owner_user1.id, app_owner_user2.id], + sync_to_okta=False, + ).execute() + + hook = get_notification_hook() + request_created_notification_spy = mocker.patch.object(hook, "access_role_request_created") + request_completed_notification_spy = mocker.patch.object(hook, "access_role_request_completed") + add_ownership_spy = mocker.patch.object(okta, "async_add_owner_to_group") + + role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=app_group, + request_ownership=True, + request_reason="test reason", + ).execute() + + assert role_request is not None + assert request_created_notification_spy.call_count == 1 + _, kwargs = request_created_notification_spy.call_args + assert kwargs["role_request"] == role_request + assert kwargs["role"] == role_group + assert kwargs["group"] == app_group + assert kwargs["requester"] == user + assert len(kwargs["approvers"]) == 2 + assert app_owner_user1 in kwargs["approvers"] + assert app_owner_user2 in kwargs["approvers"] + + ApproveRoleRequest(role_request=role_request, approver_user=app_owner_user1).execute() + + assert add_ownership_spy.call_count == 1 + assert request_completed_notification_spy.call_count == 1 + _, kwargs = request_completed_notification_spy.call_args + assert kwargs["role_request"] == role_request + assert kwargs["role"] == role_group + assert kwargs["group"] == app_group + assert kwargs["requester"] == user + assert len(kwargs["approvers"]) == 4 + assert access_admin in kwargs["approvers"] + assert app_owner_user1 in kwargs["approvers"] + assert app_owner_user2 in kwargs["approvers"] + assert user in kwargs["approvers"] + + # Reset the access request so we can test the reject path + role_request.status = AccessRequestStatus.PENDING + role_request.resolved_at = None + db.session.commit() + + add_ownership_spy.reset_mock() + request_completed_notification_spy.reset_mock() + + RejectRoleRequest(role_request=role_request, current_user_id=app_owner_user1).execute() + + assert add_ownership_spy.call_count == 0 + assert request_completed_notification_spy.call_count == 1 + _, kwargs = request_completed_notification_spy.call_args + assert kwargs["role_request"] == role_request + assert kwargs["role"] == role_group + assert kwargs["group"] == app_group + assert kwargs["requester"] == user + assert len(kwargs["approvers"]) == 4 + assert access_admin in kwargs["approvers"] + assert app_owner_user1 in kwargs["approvers"] + assert app_owner_user2 in kwargs["approvers"] + assert user in kwargs["approvers"] + + +def test_auto_resolve_create_role_request( + app: Flask, + db: SQLAlchemy, + okta_group: OktaGroup, + role_group: RoleGroup, + user: OktaUser, + tag: Tag, + mocker: MockerFixture, +) -> None: + db.session.add(user) + db.session.add(role_group) + db.session.add(okta_group) + db.session.add(tag) + db.session.commit() + + db.session.add(OktaGroupTagMap(group_id=okta_group.id, tag_id=tag.id)) + db.session.commit() + + ModifyGroupUsers(group=role_group, members_to_add=[user.id], owners_to_add=[user.id], sync_to_okta=False).execute() + + notification_hook = get_notification_hook() + request_created_notification_spy = mocker.patch.object(notification_hook, "access_role_request_created") + request_completed_notification_spy = mocker.patch.object(notification_hook, "access_role_request_completed") + request_hook = get_conditional_access_hook() + request_created_conditional_access_spy = mocker.patch.object( + request_hook, + "role_request_created", + return_value=[ConditionalAccessResponse(approved=True, reason="Auto-Approved")], + ) + add_membership_spy = mocker.patch.object(okta, "async_add_user_to_group") + + role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=okta_group, + request_ownership=False, + request_reason="test reason", + ).execute() + + assert role_request is not None + assert role_request.status == AccessRequestStatus.APPROVED + assert role_request.resolved_at is not None + assert role_request.resolver_user_id is None + assert role_request.resolution_reason == "Auto-Approved" + assert request_created_notification_spy.call_count == 0 + assert request_completed_notification_spy.call_count == 0 + assert request_created_conditional_access_spy.call_count == 1 + assert add_membership_spy.call_count == 1 + + _, kwargs = request_created_conditional_access_spy.call_args + assert role_request == kwargs["role_request"] + assert role_group == kwargs["role"] + assert okta_group == kwargs["group"] + assert user == kwargs["requester"] + assert len(kwargs["group_tags"]) == 1 + assert tag in kwargs["group_tags"] + + request_created_notification_spy.reset_mock() + request_completed_notification_spy.reset_mock() + request_created_conditional_access_spy.reset_mock() + add_membership_spy.reset_mock() + + request_created_conditional_access_spy = mocker.patch.object( + request_hook, + "role_request_created", + return_value=[ConditionalAccessResponse(approved=False, reason="Auto-Rejected")], + ) + + role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=okta_group, + request_ownership=False, + request_reason="test reason", + ).execute() + + assert role_request is not None + assert role_request.status == AccessRequestStatus.REJECTED + assert role_request.resolved_at is not None + assert role_request.resolver_user_id is None + assert role_request.resolution_reason == "Auto-Rejected" + assert request_created_notification_spy.call_count == 0 + assert request_completed_notification_spy.call_count == 0 + assert request_created_conditional_access_spy.call_count == 1 + assert add_membership_spy.call_count == 0 + + _, kwargs = request_created_conditional_access_spy.call_args + assert role_request == kwargs["role_request"] + assert role_group == kwargs["role"] + assert okta_group == kwargs["group"] + assert user == kwargs["requester"] + assert len(kwargs["group_tags"]) == 1 + assert tag in kwargs["group_tags"] + + request_created_notification_spy.reset_mock() + request_completed_notification_spy.reset_mock() + request_created_conditional_access_spy.reset_mock() + add_membership_spy.reset_mock() + + request_created_conditional_access_spy = mocker.patch.object( + request_hook, "role_request_created", return_value=[None] + ) + + role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=okta_group, + request_ownership=False, + request_reason="test reason", + ).execute() + + assert role_request is not None + assert role_request.status == AccessRequestStatus.PENDING + assert role_request.resolved_at is None + assert role_request.resolver_user_id is None + assert role_request.resolution_reason == "" + assert request_created_notification_spy.call_count == 1 + assert request_completed_notification_spy.call_count == 0 + assert request_created_conditional_access_spy.call_count == 1 + assert add_membership_spy.call_count == 0 + + _, kwargs = request_created_conditional_access_spy.call_args + assert role_request == kwargs["role_request"] + assert okta_group == kwargs["group"] + assert user == kwargs["requester"] + assert len(kwargs["group_tags"]) == 1 + assert tag in kwargs["group_tags"] + + +def test_auto_resolve_create_role_request_with_time_limit_constraint_tag( + app: Flask, + db: SQLAlchemy, + role_group: RoleGroup, + okta_group: OktaGroup, + user: OktaUser, + tag: Tag, + mocker: MockerFixture, +) -> None: + db.session.add(user) + db.session.add(role_group) + db.session.add(okta_group) + tag.constraints = { + Tag.MEMBER_TIME_LIMIT_CONSTRAINT_KEY: THREE_DAYS_IN_SECONDS, + Tag.OWNER_TIME_LIMIT_CONSTRAINT_KEY: THREE_DAYS_IN_SECONDS, + } + db.session.add(tag) + db.session.commit() + + db.session.add(OktaGroupTagMap(group_id=okta_group.id, tag_id=tag.id)) + db.session.commit() + + ModifyGroupUsers(group=role_group, members_to_add=[user.id], owners_to_add=[user.id], sync_to_okta=False).execute() + + notification_hook = get_notification_hook() + request_created_notification_spy = mocker.patch.object(notification_hook, "access_role_request_created") + request_completed_notification_spy = mocker.patch.object(notification_hook, "access_role_request_completed") + request_hook = get_conditional_access_hook() + request_created_conditional_access_spy = mocker.patch.object( + request_hook, + "role_request_created", + return_value=[ + ConditionalAccessResponse( + approved=True, + reason="Auto-Approved", + ending_at=datetime.now() + timedelta(seconds=SEVEN_DAYS_IN_SECONDS), + ), + ], + ) + add_membership_spy = mocker.patch.object(okta, "async_add_user_to_group") + + role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=okta_group, + request_ownership=False, + request_reason="test reason", + ).execute() + + assert role_request is not None + assert role_request.status == AccessRequestStatus.APPROVED + assert role_request.resolved_at is not None + assert role_request.resolver_user_id is None + assert role_request.resolution_reason == "Auto-Approved" + assert request_created_notification_spy.call_count == 0 + assert request_completed_notification_spy.call_count == 0 + assert request_created_conditional_access_spy.call_count == 1 + assert add_membership_spy.call_count == 1 + + _, kwargs = request_created_conditional_access_spy.call_args + assert role_request == kwargs["role_request"] + assert role_group == kwargs["role"] + assert okta_group == kwargs["group"] + assert user == kwargs["requester"] + assert len(kwargs["group_tags"]) == 1 + assert tag in kwargs["group_tags"] + + +def test_role_request_approval_via_direct_add( + client: FlaskClient, + app: Flask, + db: SQLAlchemy, + role_group: RoleGroup, + okta_group: OktaGroup, + user: OktaUser, + mocker: MockerFixture, +) -> None: + okta_group2 = OktaGroupFactory.create() + + db.session.add(user) + db.session.add(role_group) + db.session.add(okta_group) + db.session.add(okta_group2) + db.session.commit() + + ModifyGroupUsers(group=role_group, members_to_add=[user.id], owners_to_add=[user.id], sync_to_okta=False).execute() + + hook = get_notification_hook() + request_created_notification_spy = mocker.patch.object(hook, "access_role_request_created") + request_completed_notification_spy = mocker.patch.object(hook, "access_role_request_completed") + + role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=okta_group, + request_ownership=False, + request_reason="test reason", + ).execute() + + assert role_request is not None + assert request_created_notification_spy.call_count == 1 + + access_owner = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + ModifyRoleGroups( + role_group=role_group, groups_to_add=[okta_group.id], sync_to_okta=False, created_reason="test" + ).execute() + + assert request_completed_notification_spy.call_count == 1 + _, kwargs = request_completed_notification_spy.call_args + assert kwargs["role_request"] == role_request + assert kwargs["role"] == role_group + assert kwargs["group"] == okta_group + assert kwargs["requester"] == user + assert len(kwargs["approvers"]) == 1 + assert access_owner in kwargs["approvers"] + + group_url = url_for("api-groups.group_members_by_id", group_id=okta_group.id) + rep = client.get(group_url) + assert rep.status_code == 200 + data = rep.get_json() + assert len(data["members"]) == 1 + assert len(data["owners"]) == 0 + assert data["members"][0] == user.id + + request_created_notification_spy.reset_mock() + request_completed_notification_spy.reset_mock() + + role_request = CreateRoleRequest( + requester_user=user, + requester_role=role_group, + requested_group=okta_group2, + request_ownership=True, + request_reason="test reason", + ).execute() + + assert role_request is not None + assert request_created_notification_spy.call_count == 1 + + access_owner = OktaUser.query.filter(OktaUser.email == app.config["CURRENT_OKTA_USER_EMAIL"]).first() + + ModifyRoleGroups( + role_group=role_group, owner_groups_to_add=[okta_group2.id], sync_to_okta=False, created_reason="test" + ).execute() + + assert request_completed_notification_spy.call_count == 1 + _, kwargs = request_completed_notification_spy.call_args + assert kwargs["role_request"] == role_request + assert kwargs["role"] == role_group + assert kwargs["group"] == okta_group2 + assert kwargs["requester"] == user + assert len(kwargs["approvers"]) == 2 + assert access_owner in kwargs["approvers"] + + group_url = url_for("api-groups.group_members_by_id", group_id=okta_group2.id) + rep = client.get(group_url) + assert rep.status_code == 200 + data = rep.get_json() + assert len(data["owners"]) == 1 + assert len(data["members"]) == 0 + assert data["owners"][0] == user.id From f757ac001502a2d94f82c6e0df4af71645c019a6 Mon Sep 17 00:00:00 2001 From: eguerrant <141771735+eguerrant@users.noreply.github.com> Date: Wed, 13 Nov 2024 11:45:47 -0800 Subject: [PATCH 04/13] Filter 'expiring in a week' individual notifications (#190) --- api/syncer.py | 7 +++++ tests/test_expiring_access_notifications.py | 30 +++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/api/syncer.py b/api/syncer.py index 4d33138a..183ab890 100644 --- a/api/syncer.py +++ b/api/syncer.py @@ -458,6 +458,13 @@ def expiring_access_notifications_user() -> None: .all() ) + # remove OktaUserGroupMembers from the list where there's a role that grants the same access + db_memberships_expiring_next_week = [ + member + for member in db_memberships_expiring_next_week + if (member.user_id, member.group_id) not in user_id_group_id_roles + ] + grouped_next_week: dict[OktaUser, list[OktaGroup]] = {} for membership in db_memberships_expiring_next_week: grouped_next_week.setdefault(membership.active_user, []).append(membership.active_group) diff --git a/tests/test_expiring_access_notifications.py b/tests/test_expiring_access_notifications.py index c2fc1d32..5da52472 100644 --- a/tests/test_expiring_access_notifications.py +++ b/tests/test_expiring_access_notifications.py @@ -108,6 +108,36 @@ def test_individual_expiring_direct_with_role( assert expiring_access_notification_spy.call_count == 0 +# Test with one user who has one direct membership expiring in a week and a role membership for the same group +def test_individual_expiring_direct_with_role_week( + db: SQLAlchemy, mocker: MockerFixture, user: OktaUser, okta_group: OktaGroup, role_group: RoleGroup +) -> None: + db.session.add(okta_group) + db.session.add(role_group) + db.session.add(user) + db.session.commit() + + expiration_datetime = datetime.now() + timedelta(weeks=1) + other_date = datetime.now() + timedelta(days=90) + + ModifyGroupUsers( + group=okta_group, users_added_ended_at=expiration_datetime, members_to_add=[user.id], sync_to_okta=False + ).execute() + ModifyGroupUsers( + group=role_group, users_added_ended_at=other_date, members_to_add=[user.id], sync_to_okta=False + ).execute() + ModifyRoleGroups( + role_group=role_group, groups_added_ended_at=other_date, groups_to_add=[okta_group.id], sync_to_okta=False + ).execute() + + hook = get_notification_hook() + expiring_access_notification_spy = mocker.patch.object(hook, "access_expiring_user") + + expiring_access_notifications_user() + + assert expiring_access_notification_spy.call_count == 0 + + # Test with one owner who owns two groups, each group has a member whose access expires this week def test_owner_expiring_access_notifications(db: SQLAlchemy, mocker: MockerFixture) -> None: group1 = OktaGroupFactory.create() From cf695de7ee334300d7113ab7506c1c6f551d3cfc Mon Sep 17 00:00:00 2001 From: Jonathan Le Date: Mon, 18 Nov 2024 11:44:39 -0800 Subject: [PATCH 05/13] Add allow_discord_access custom Okta Group profile attribute check (#195) --- api/config.py | 3 ++ api/services/okta_service.py | 20 +++++++++++- tests/test_okta_service.py | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/test_okta_service.py diff --git a/api/config.py b/api/config.py index 8b4b1644..26156821 100644 --- a/api/config.py +++ b/api/config.py @@ -13,6 +13,9 @@ OKTA_USE_GROUP_OWNERS_API = os.getenv("OKTA_USE_GROUP_OWNERS_API", "False") == "True" CURRENT_OKTA_USER_EMAIL = os.getenv("CURRENT_OKTA_USER_EMAIL", "wumpus@discord.com") +# Optional env var to set a custom Okta Group Profile attribute for Access management inclusion/exclusion +OKTA_GROUP_PROFILE_CUSTOM_ATTR = os.getenv("OKTA_GROUP_PROFILE_CUSTOM_ATTR") + SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URI") SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ECHO = ENV == "development" # or ENV == "test" diff --git a/api/services/okta_service.py b/api/services/okta_service.py index d5a9bb69..2b4fe6ac 100644 --- a/api/services/okta_service.py +++ b/api/services/okta_service.py @@ -14,6 +14,7 @@ from okta.models.user_schema import UserSchema as OktaUserSchemaType from okta.request_executor import RequestExecutor as OktaRequestExecutor +from api.config import OKTA_GROUP_PROFILE_CUSTOM_ATTR from api.models import OktaGroup, OktaUser REQUEST_MAX_RETRIES = 3 @@ -534,5 +535,22 @@ def update_okta_group( return okta_group -def is_managed_group(group: Group, group_ids_with_group_rules: dict[str, list[OktaGroupRuleType]]) -> bool: +def is_managed_group( + group: Group, + group_ids_with_group_rules: dict[str, list[OktaGroupRuleType]], + custom_attr: Optional[str] = OKTA_GROUP_PROFILE_CUSTOM_ATTR, +) -> bool: + # Check if OKTA_GROUP_PROFILE_CUSTOM_ATTR attribute exists as a custom Okta Group Profile attribute and retrieve its value + if custom_attr: + custom_manage_attr = getattr(group.profile, custom_attr, None) + + # If OKTA_GROUP_PROFILE_CUSTOM_ATTR is explicitly set to False, the group should not be managed + if custom_manage_attr is False: + return False + + # If OKTA_GROUP_PROFILE_CUSTOM_ATTR is True and the group type is OKTA_GROUP, it can be managed even if it has group rules + if custom_manage_attr is True and group.type == "OKTA_GROUP": + return True + + # By default, the group should be of type OKTA_GROUP and should not have any group rules to be managed return (group.type == "OKTA_GROUP") and (group.id not in group_ids_with_group_rules) diff --git a/tests/test_okta_service.py b/tests/test_okta_service.py new file mode 100644 index 00000000..666308d7 --- /dev/null +++ b/tests/test_okta_service.py @@ -0,0 +1,62 @@ +from unittest.mock import MagicMock, patch + +from okta.models.group_rule import GroupRule as OktaGroupRuleType + +from api.services.okta_service import is_managed_group + + +def test_is_managed_group_with_allow_discord_access_false() -> None: + """Test that is_managed_group returns False when allow_discord_access is False.""" + with patch("api.config.OKTA_GROUP_PROFILE_CUSTOM_ATTR", "allow_discord_access"): + from api.config import OKTA_GROUP_PROFILE_CUSTOM_ATTR + + # Create a mock of the Group class + group = MagicMock() + group.profile = MagicMock() + group.profile.allow_discord_access = False # Set the profile attribute to False + group.type = "OKTA_GROUP" + group.id = "123456789" # Example group ID + + group_ids_with_group_rules: dict[str, list[OktaGroupRuleType]] = {} # Empty dictionary for group rules + + # Call the function and assert the expected result + result = is_managed_group(group, group_ids_with_group_rules, OKTA_GROUP_PROFILE_CUSTOM_ATTR) + assert result is False + + +def test_is_managed_group_with_allow_discord_access_true() -> None: + """Test that is_managed_group returns True when allow_discord_access is True.""" + with patch("api.config.OKTA_GROUP_PROFILE_CUSTOM_ATTR", "allow_discord_access"): + from api.config import OKTA_GROUP_PROFILE_CUSTOM_ATTR + + # Create a mock of the Group class + group = MagicMock() + group.profile = MagicMock() + group.profile.allow_discord_access = True # Set the profile attribute to True + group.type = "OKTA_GROUP" + group.id = "123456789" # Example group ID + + group_ids_with_group_rules: dict[str, list[OktaGroupRuleType]] = {} # Empty dictionary for group rules + + # Call the function and assert the expected result + result = is_managed_group(group, group_ids_with_group_rules, OKTA_GROUP_PROFILE_CUSTOM_ATTR) + assert result is True + + +def test_is_managed_group_with_allow_discord_access_undefined() -> None: + """Test that is_managed_group returns True when the custom attribute is undefined.""" + with patch("api.config.OKTA_GROUP_PROFILE_CUSTOM_ATTR", None): + from api.config import OKTA_GROUP_PROFILE_CUSTOM_ATTR + + # Create a mock of the Group class + group = MagicMock() + group.profile = MagicMock() + group.profile.allow_discord_access = False # Set the profile attribute to False + group.type = "OKTA_GROUP" + group.id = "123456789" # Example group ID + + group_ids_with_group_rules: dict[str, list[OktaGroupRuleType]] = {} # Empty dictionary for group rules + + # Call the function and assert the expected result + result = is_managed_group(group, group_ids_with_group_rules, OKTA_GROUP_PROFILE_CUSTOM_ATTR) + assert result is True From 721afd20d1eb0211a9bd7b0b9e4bebe4cc255405 Mon Sep 17 00:00:00 2001 From: Jonathan Le Date: Mon, 25 Nov 2024 10:59:38 -0800 Subject: [PATCH 06/13] Add ability to dynamically extend flask cli management commands (#201) --- Dockerfile | 2 +- api/app.py | 14 ++++- api/config.py | 3 + .../plugins/health_check_plugin/README.md | 50 ++++++++++++++++ .../plugins/health_check_plugin/__init__.py | 7 +++ examples/plugins/health_check_plugin/cli.py | 59 +++++++++++++++++++ examples/plugins/health_check_plugin/setup.py | 16 +++++ 7 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 examples/plugins/health_check_plugin/README.md create mode 100644 examples/plugins/health_check_plugin/__init__.py create mode 100644 examples/plugins/health_check_plugin/cli.py create mode 100644 examples/plugins/health_check_plugin/setup.py diff --git a/Dockerfile b/Dockerfile index 92754ea0..5173c28d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ ARG PUSH_SENTRY_RELEASE="false" # Build step #1: build the React front end -FROM node:23-alpine AS build-step +FROM node:22-alpine AS build-step ARG SENTRY_RELEASE="" WORKDIR /app ENV PATH /app/node_modules/.bin:$PATH diff --git a/api/app.py b/api/app.py index 38557d0d..8c041c78 100644 --- a/api/app.py +++ b/api/app.py @@ -5,6 +5,7 @@ import logging import sys import warnings +from importlib.metadata import entry_points from os import environ from typing import Optional @@ -183,6 +184,7 @@ def add_headers(response: Response) -> ResponseReturnValue: ########################################## # Configure flask cli commands ########################################## + # Register static commands app.cli.add_command(manage.init) app.cli.add_command(manage.import_from_okta) app.cli.add_command(manage.init_builtin_apps) @@ -191,6 +193,16 @@ def add_headers(response: Response) -> ResponseReturnValue: app.cli.add_command(manage.fix_role_memberships) app.cli.add_command(manage.notify) + # Register dynamically loaded commands + flask_commands = entry_points(group="flask.commands") + + for entry_point in flask_commands: + try: + command = entry_point.load() + app.cli.add_command(command) + except Exception as e: + logger.warning(f"Failed to load command '{entry_point.name}': {e}") + ########################################### # Configure APISpec for swagger support ########################################### @@ -204,7 +216,7 @@ def add_headers(response: Response) -> ResponseReturnValue: # https://github.com/marshmallow-code/apispec/issues/444 warnings.filterwarnings("ignore", message="Multiple schemas resolved to the name ") # Ignore the following warning because nested schemas may declare less fields via only tuples - # than the actual schema has specfieid in the fields tuple + # than the actual schema has specified in the fields tuple warnings.filterwarnings("ignore", message="Only explicitly-declared fields will be included in the Schema Object") app.register_blueprint(exception_views.bp) diff --git a/api/config.py b/api/config.py index 26156821..558f1d01 100644 --- a/api/config.py +++ b/api/config.py @@ -83,3 +83,6 @@ def default_user_search() -> list[str]: FLASK_SENTRY_DSN = os.getenv("FLASK_SENTRY_DSN") REACT_SENTRY_DSN = os.getenv("REACT_SENTRY_DSN") + +# Add APP_VERSION, defaulting to 'Not Defined' if not set +APP_VERSION = os.getenv("APP_VERSION", "Not Defined") diff --git a/examples/plugins/health_check_plugin/README.md b/examples/plugins/health_check_plugin/README.md new file mode 100644 index 00000000..dfecaf61 --- /dev/null +++ b/examples/plugins/health_check_plugin/README.md @@ -0,0 +1,50 @@ +# Health Check Plugin + +This is an example plugin that demonstrates how to extend Flask CLI commands using plugins. The `health_check_plugin` adds a custom `health` command to the Flask CLI, which performs a health check of the application, including verifying database connectivity. + +## Overview + +The plugin consists of the following files: + +- **`__init__.py`**: Initializes the plugin by defining an `init_app` function that registers the CLI commands. +- **`cli.py`**: Contains the implementation of the `health` command. +- **`setup.py`**: Defines the plugin's setup configuration and registers the entry point for the CLI command. + +## Installation + +To install the plugin the App container Dockerfile + +``` +WORKDIR /app/plugins +ADD ./examples/plugins/health_check_plugin ./health_check_plugin +RUN pip install ./health_check_plugin + +# Reset working directory +WORKDIR /app +``` + +## Usage + +After installing the plugin, the `health` command becomes available in the Flask CLI: + +```bash +flask health +``` + +This command outputs the application's health status in JSON format, indicating the database connection status and the application version. + +## Purpose + +This plugin serves as an example of how to extend Flask CLI commands using plugins and entry points. It demonstrates: + +- How to create a custom CLI command in a plugin. +- How to register the command using entry points in `setup.py`. + +By following this example, you can create your own plugins to extend the functionality of your Flask application's CLI in a modular and scalable way. + +## Files + +- **[`__init__.py`](./__init__.py)**: Plugin initialization code. +- **[`cli.py`](./cli.py)**: Implementation of the `health` CLI command. +- **[`setup.py`](./setup.py)**: Setup script defining the plugin metadata and entry points. + diff --git a/examples/plugins/health_check_plugin/__init__.py b/examples/plugins/health_check_plugin/__init__.py new file mode 100644 index 00000000..bbaee7ba --- /dev/null +++ b/examples/plugins/health_check_plugin/__init__.py @@ -0,0 +1,7 @@ +from flask import Flask + + +def init_app(app: Flask) -> None: + from .cli import health_command + + app.cli.add_command(health_command) diff --git a/examples/plugins/health_check_plugin/cli.py b/examples/plugins/health_check_plugin/cli.py new file mode 100644 index 00000000..f570c83d --- /dev/null +++ b/examples/plugins/health_check_plugin/cli.py @@ -0,0 +1,59 @@ +import logging + +import click +from flask.cli import with_appcontext +from sqlalchemy import text + + +@click.command("health") +@with_appcontext +def health_command() -> None: + """Displays application database health and metrics in JSON format.""" + from flask import current_app, json + + from api.extensions import db + + logger = logging.getLogger(__name__) + + try: + # Perform a simple database health check using SQLAlchemy + db.session.execute(text("SELECT 1")) + db_status = "connected" + error = None + logger.info("Database connection successful.") + + # Retrieve all table names and their row counts + tables_query = text(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public'; + """) + tables = db.session.execute(tables_query).fetchall() + + table_sizes = {} + for table in tables: + table_name = table[0] + row_count_query = text(f"SELECT COUNT(*) FROM {table_name}") + row_count = db.session.execute(row_count_query).scalar() + table_sizes[table_name] = row_count + + except Exception as e: + db_status = "disconnected" + error = str(e) + table_sizes = {} + logger.error(f"Database connection error: {error}") + + # Prepare the health status response + status = { + "status": "ok" if db_status == "connected" else "error", + "database": db_status, + "tables": table_sizes, + "version": current_app.config.get("APP_VERSION", "Not Defined"), + **({"error": error} if error else {}), + } + + # Log the health status + logger.info(f"Health status: {status}") + + # Output the health status as a JSON string + click.echo(json.dumps(status)) diff --git a/examples/plugins/health_check_plugin/setup.py b/examples/plugins/health_check_plugin/setup.py new file mode 100644 index 00000000..dea404ed --- /dev/null +++ b/examples/plugins/health_check_plugin/setup.py @@ -0,0 +1,16 @@ +from setuptools import setup + +setup( + name="health_check_plugin", + version="0.1.0", + packages=["health_check_plugin"], + package_dir={"health_check_plugin": "."}, # Map package to current directory + install_requires=[ + "Flask", + ], + entry_points={ + "flask.commands": [ + "health=health_check_plugin.cli:health_command", + ], + }, +) From 0863e72cb23e19757a4113da35bb5fbe61c07137 Mon Sep 17 00:00:00 2001 From: Jonathan Le Date: Mon, 25 Nov 2024 11:10:46 -0800 Subject: [PATCH 07/13] Proposing Slack Notifications example (#199) --- .../plugins/notifications_slack/README.md | 64 ++++ .../plugins/notifications_slack/__init__.py | 0 .../notifications_slack/notifications.py | 276 ++++++++++++++++++ .../notifications_slack/requirements.txt | 2 + examples/plugins/notifications_slack/setup.py | 10 + 5 files changed, 352 insertions(+) create mode 100644 examples/plugins/notifications_slack/README.md create mode 100644 examples/plugins/notifications_slack/__init__.py create mode 100644 examples/plugins/notifications_slack/notifications.py create mode 100644 examples/plugins/notifications_slack/requirements.txt create mode 100644 examples/plugins/notifications_slack/setup.py diff --git a/examples/plugins/notifications_slack/README.md b/examples/plugins/notifications_slack/README.md new file mode 100644 index 00000000..1d3eba84 --- /dev/null +++ b/examples/plugins/notifications_slack/README.md @@ -0,0 +1,64 @@ +# Discord Access Slack Notifications Plugin + +This plugin integrates Discord access notifications with Slack, allowing users to receive updates and alerts regarding their access requests and expirations directly in Slack. + +## Installation + +Update the Dockerfile used to build the App container includes the following section for installing the notifications plugin before starting gunicorn: +```dockerfile +# Add the specific plugins and install notifications +WORKDIR /app/plugins +ADD ./examples/plugins/notifications_slack ./notifications_slack +RUN pip install -r ./notifications_slack/requirements.txt && pip install ./notifications_slack + +# Reset working directory +WORKDIR /app + +ENV FLASK_ENV production +ENV FLASK_APP api.app:create_app +ENV SENTRY_RELEASE $SENTRY_RELEASE + +EXPOSE 3000 + +CMD ["gunicorn", "-w", "4", "-t", "600", "-b", ":3000", "--access-logfile", "-", "api.wsgi:app"] +``` + +## Build the Docker image, run and test + +You may use the original Discord Access container build processes from the primary README.md: +```bash +docker compose up --build +``` + +Verify Slack notifications are work as designed. + +## Plugin Configuration + +The plugin requires the following environment variables to be set: + +- `SLACK_BOT_TOKEN`: The token for your Slack bot. +- `SLACK_ALERTS_CHANNEL`: The channel where alerts will be sent. String name like `#alerts-discord-access` +- `CLIENT_ORIGIN_URL`: The base URL for your application. + +## Plugin Structure + +The plugin consists of the following components: + +- **Notifications Slack**: This component handles sending notifications to Slack when access requests are created, completed, or expiring. + +## Usage + +After installing and setting up the plugin, it automatically sends notifications to the relevant users and owners when an access request is created, completed, or is about to expire. You can also choose to send these notifications to a designated Slack alerts channel for logging and better visibility by setting SLACK_ALERTS_CHANNEL. + +## Development + +To contribute to the development of this plugin, please follow the standard Git workflow: + +1. Fork the repository. +2. Create a new branch for your feature or bug fix. +3. Make your changes and commit them. +4. Push your branch and create a pull request. + +## License + +This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. diff --git a/examples/plugins/notifications_slack/__init__.py b/examples/plugins/notifications_slack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/plugins/notifications_slack/notifications.py b/examples/plugins/notifications_slack/notifications.py new file mode 100644 index 00000000..90d4b4c4 --- /dev/null +++ b/examples/plugins/notifications_slack/notifications.py @@ -0,0 +1,276 @@ +from __future__ import print_function + +import logging +import os +from datetime import date, datetime, timedelta +from typing import List, Optional + +import pluggy +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from api.models import AccessRequest, OktaGroup, OktaUser, RoleGroup + +notification_hook_impl = pluggy.HookimplMarker("access_notifications") +logger = logging.getLogger(__name__) + +# Initialize Slack client and signature verifier +slack_token = os.environ["SLACK_BOT_TOKEN"] +client = WebClient(token=slack_token) +alerts_channel = os.environ.get("SLACK_ALERTS_CHANNEL") +CLIENT_ORIGIN_URL = os.environ.get("CLIENT_ORIGIN_URL") # e.g. "https://discord-access-instance.com" + + +def get_base_url() -> str: + """Get the base URL for the environment. + + Returns: + str: The base URL based on the environment. + """ + # Use CLIENT_ORIGIN_URL if defined; otherwise, determine based on the environment. + if CLIENT_ORIGIN_URL: + return CLIENT_ORIGIN_URL + + env = os.environ.get("FLASK_ENV", "development") + if env == "production": + return "https://example.com" + else: + return "http://localhost:3000" + + +def parse_dates(comparison_date: datetime, owner: bool) -> str: + """Parse dates for notification messages. + + Args: + comparison_date (datetime): The date to compare. + owner (bool): Indicates if the user is an owner. + + Returns: + str: The parsed date description. + """ + if not comparison_date: + return "soon" + + just_date = comparison_date.date() + today = date.today() + + prev_monday = today - timedelta(days=today.weekday()) + + if owner: # 'round' dates to previous Monday + if prev_monday <= just_date and just_date < prev_monday + timedelta(weeks=1): + return "this week" + elif prev_monday + timedelta(weeks=1) <= just_date and just_date < prev_monday + timedelta(weeks=2): + return "next week" + elif prev_monday + timedelta(weeks=2) <= just_date and just_date < prev_monday + timedelta(weeks=3): + return "in two weeks" + elif prev_monday + timedelta(weeks=3) <= just_date and just_date < prev_monday + timedelta(weeks=4): + return "in three weeks" + elif prev_monday + timedelta(weeks=4) <= just_date and just_date < prev_monday + timedelta(weeks=5): + return "in four weeks" + else: + if just_date == today + timedelta(days=1): # one day before + return "tomorrow" + elif just_date == today + timedelta(weeks=1): # one week before + return "in a week" + elif just_date == today + timedelta(weeks=2): # 2 weeks before + return "in two weeks" + + return "soon" + + +def get_user_id_by_email(email: str) -> Optional[str]: + """Get Slack user ID by email. + + Args: + email (str): The email of the user. + + Returns: + Optional[str]: The Slack user ID if found, otherwise None. + """ + try: + response = client.users_lookupByEmail(email=email) + return response["user"]["id"] + except SlackApiError as e: + logger.error(f"Error fetching user ID for {email}: {e.response['error']}") + return None + + +def send_slack_dm(user: OktaUser, message: str) -> None: + """Send a direct message to a Slack user. + + Args: + user (OktaUser): The user to send the message to. + message (str): The message content. + """ + user_id = get_user_id_by_email(user.email) + if user_id: + mention_message = f"<@{user_id}> {message}" + try: + response = client.chat_postMessage( + channel=user_id, text=mention_message, as_user=True, unfurl_links=True, unfurl_media=True + ) + logger.info(f"Slack DM sent: {response['ts']}") + except SlackApiError as e: + logger.error(f"Error sending Slack message: {e.response['error']}") + + +def send_slack_channel_message(message: str) -> None: + """Send a message to a Slack channel if the alerts_channel is defined. + + Args: + message (str): The message content. + """ + if alerts_channel: + try: + response = client.chat_postMessage( + channel=alerts_channel, text=message, as_user=True, unfurl_links=True, unfurl_media=True + ) + logger.info(f"Slack channel message sent: {response['ts']}") + except SlackApiError as e: + logger.error(f"Error sending Slack channel message: {e.response['error']}") + + +@notification_hook_impl +def access_request_created( + access_request: AccessRequest, group: OktaGroup, requester: OktaUser, approvers: List[OktaUser] +) -> None: + """Notify all the approvers of the access request through a notification. + + Args: + access_request (AccessRequest): The access request. + group (OktaGroup): The group for which access is requested. + requester (OktaUser): The user requesting access. + approvers (List[OktaUser]): The list of approvers. + """ + type_of_access = "ownership of" if access_request.request_ownership else "membership to" + + access_request_url = get_base_url() + f"/requests/{access_request.id}" + + approver_message = ( + f":pray: {requester.email} has requested {type_of_access} {group.name}.\n\n" + f"<{access_request_url}|View request to approve or reject>\n\n" + ) + + # Send the message to the approvers + for approver in approvers: + send_slack_dm(approver, approver_message) + logger.info(f"Approver message: {approver_message}") + + # Post to the alerts channel + send_slack_channel_message(approver_message) + + +@notification_hook_impl +def access_request_completed( + access_request: AccessRequest, + group: OktaGroup, + requester: OktaUser, + approvers: List[OktaUser], + notify_requester: bool, +) -> None: + """Notify the requester that their access request has been processed. + + Args: + access_request (AccessRequest): The access request. + group (OktaGroup): The group for which access is requested. + requester (OktaUser): The user requesting access. + approvers (List[OktaUser]): The list of approvers. + notify_requester (bool): Whether to notify the requester. + """ + access_request_url = get_base_url() + f"/requests/{access_request.id}" + emoji = ":white_check_mark:" if access_request.status.lower() == "approved" else ":x:" + + requester_message = ( + f"{emoji} Request for access to {group.name} has been {access_request.status.lower()}.\n\n" + f"<{access_request_url}|View request>\n" + ) + + # Send the message to the requester + if notify_requester: + send_slack_dm(requester, requester_message) + logger.info(f"Requester message: {requester_message}") + + # Post to the alerts channel + send_slack_channel_message(requester_message) + + +@notification_hook_impl +def access_expiring_user(groups: List[OktaGroup], user: OktaUser, expiration_datetime: datetime) -> None: + """Notify individuals that their access to a group is expiring soon. + + Args: + groups (List[OktaGroup]): The list of groups. + user (OktaUser): The user whose access is expiring. + expiration_datetime (datetime): The expiration date and time. + """ + expiring_access_url = get_base_url() + "/expiring-groups?user_id=@me" + + group_or_groups = f"{len(groups)} groups" if len(groups) > 1 else f"the group {groups[0].name}" + + message = ( + f"Your access to {group_or_groups} is expiring {parse_dates(expiration_datetime, False)}.\n\n" + f"Click <{expiring_access_url}|here> to view your access and, if still needed, create a request to renew it." + ) + + # Send the message to the individual user with expiring access + send_slack_dm(user, message) + logger.info(f"User message: {message}") + + # Post to the alerts channel + send_slack_channel_message(message) + + +@notification_hook_impl +def access_expiring_owner( + owner: OktaUser, + groups: List[OktaGroup], + roles: List[OktaGroup], + users: List[RoleGroup], + expiration_datetime: datetime, +) -> None: + """Notify group owners that individuals or roles access to a group is expiring soon. + + Args: + owner (OktaUser): The owner of the group. + groups (List[OktaGroup]): The list of groups. + roles (List[OktaGroup]): The list of roles. + users (List[RoleGroup]): The list of users. + expiration_datetime (datetime): The expiration date and time. + """ + if users is not None and len(users) > 0: + expiring_access_url = get_base_url() + "/expiring-groups?owner_id=@me" + + single_or_group = "A member or owner" if len(users) == 1 else "Members or owners" + group_or_groups = "a group" if len(groups) == 1 else "groups" + + message = ( + f"{single_or_group} of {group_or_groups} you own will lose access {parse_dates(expiration_datetime, True)}.\n\n" + f"Click <{expiring_access_url}|here> to review the owners and members with expiring access and determine if the " + f"access is still appropriate. If so, renew their membership/ownership so they do not lose access." + ) + + # Send the message to the group owner about the users with expiring access + send_slack_dm(owner, message) + logger.info(f"Owner message: {message}") + + # Post to the alerts channel + send_slack_channel_message(message) + + if roles is not None and len(roles) > 0: + expiring_access_url = get_base_url() + "/expiring-roles?owner_id=@me" + + (single_or_group, is_are) = ("A role", "is") if len(roles) == 1 else ("Roles", "are") + group_or_groups = "a group" if len(groups) == 1 else "groups" + + message = ( + f"{single_or_group} that {is_are} granted access to {group_or_groups} you own will lose access " + f"{parse_dates(expiration_datetime, True)}.\n\n" + f"Click <{expiring_access_url}|here> to view expiring roles and, if still appropriate, renew their access." + ) + + # Send the message to the group owner about the roles with expiring access + send_slack_dm(owner, message) + logger.info(f"Owner message: {message}") + + # Post to the alerts channel + send_slack_channel_message(message) diff --git a/examples/plugins/notifications_slack/requirements.txt b/examples/plugins/notifications_slack/requirements.txt new file mode 100644 index 00000000..1162e53d --- /dev/null +++ b/examples/plugins/notifications_slack/requirements.txt @@ -0,0 +1,2 @@ +pluggy==1.4.0 +slack-sdk==3.27.2 \ No newline at end of file diff --git a/examples/plugins/notifications_slack/setup.py b/examples/plugins/notifications_slack/setup.py new file mode 100644 index 00000000..f3986c27 --- /dev/null +++ b/examples/plugins/notifications_slack/setup.py @@ -0,0 +1,10 @@ +from setuptools import setup + +setup( + name="access-notifications", + install_requires=["pluggy==1.4.0"], + py_modules=["notifications"], + entry_points={ + "access_notifications": ["notifications = notifications"], + }, +) From 59ec88d11813721becf2dcc252c9f1c3b061d3fe Mon Sep 17 00:00:00 2001 From: Matthew Hintz <92115109+mhintz-clickup@users.noreply.github.com> Date: Mon, 25 Nov 2024 14:11:34 -0500 Subject: [PATCH 08/13] feat(conditional-access): changed this to work with tags as well as group names (#200) --- examples/plugins/conditional_access/Readme.md | 31 +++++++++++++++++++ .../conditional_access/conditional_access.py | 27 +++++++++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 examples/plugins/conditional_access/Readme.md diff --git a/examples/plugins/conditional_access/Readme.md b/examples/plugins/conditional_access/Readme.md new file mode 100644 index 00000000..0d2abfd8 --- /dev/null +++ b/examples/plugins/conditional_access/Readme.md @@ -0,0 +1,31 @@ +# Conditional Access Plugin + +This plugin will allow you to automatically approve or deny access requests based on the group or tag membership of the group. + +## Installation + +Add the below to your Dockerfile to install the plugin. You can put it before the ENV section at the bottom of the file. +``` +# Add the specific plugins and install conditional access plugin +WORKDIR /app/plugins +ADD ./examples/plugins/conditional_access ./conditional_access +RUN pip install -r ./conditional_access/requirements.txt && pip install ./conditional_access + +# Reset working directory +WORKDIR /app +``` + +Build and run your docker container as normal. + + +## Configuration + +You can set the following environment variables to configure the plugin but note that neither are required by default. If you only want to use the specific tag `Auto-Approve` then no environment variables are required. You must however create the tag within the Access Application. + +- `AUTO_APPROVED_GROUP_NAMES`: A comma-separated list of group names that will be auto-approved. +- `AUTO_APPROVED_TAG_NAMES`: A comma-separated list of tag names that will be auto-approved. + + +## Usage + +The plugin will automatically approve access requests to the groups or tags specified in the environment variables by running a check on each access request that is processed. If neither the group name nor the tag name match, then a log line stating manual approval is required will be output. diff --git a/examples/plugins/conditional_access/conditional_access.py b/examples/plugins/conditional_access/conditional_access.py index 487263d2..522d4da6 100644 --- a/examples/plugins/conditional_access/conditional_access.py +++ b/examples/plugins/conditional_access/conditional_access.py @@ -1,6 +1,7 @@ from __future__ import print_function import logging +import os from typing import List, Optional import pluggy @@ -11,6 +12,17 @@ request_hook_impl = pluggy.HookimplMarker("access_conditional_access") logger = logging.getLogger(__name__) +# Constants for auto-approval conditions (not required if you only want to use the Auto-Approval TAG) +# Example of how to set this in an environment variable in your .env.production file: +# AUTO_APPROVED_GROUP_NAMES="Group1,Group2,Group3" +AUTO_APPROVED_GROUP_NAMES = ( + os.getenv("AUTO_APPROVED_GROUP_NAMES", "").split(",") if os.getenv("AUTO_APPROVED_GROUP_NAMES") else [] +) + +# Example of how to set this in an environment variable in your .env.production file: +# AUTO_APPROVED_TAG_NAMES="Tag1,Tag2,Tag3" +AUTO_APPROVED_TAG_NAMES = os.getenv("AUTO_APPROVED_TAG_NAMES", "Auto-Approve").split(",") + @request_hook_impl def access_request_created( @@ -18,11 +30,16 @@ def access_request_created( ) -> Optional[ConditionalAccessResponse]: """Auto-approve memberships to the Auto-Approved-Group group""" - if not access_request.request_ownership and group.name == "Auto-Approved-Group": - logger.info(f"Auto-approving access request {access_request.id} to group {group.name}") - return ConditionalAccessResponse( - approved=True, reason="Group membership auto-approved", ending_at=access_request.request_ending_at - ) + if not access_request.request_ownership: + # Check either group name or tag for auto-approval + is_auto_approved_name = group.name in AUTO_APPROVED_GROUP_NAMES + is_auto_approved_tag = any(tag.name in AUTO_APPROVED_TAG_NAMES for tag in group_tags) + + if is_auto_approved_name or is_auto_approved_tag: + logger.info(f"Auto-approving access request {access_request.id} to group {group.name}") + return ConditionalAccessResponse( + approved=True, reason="Group membership auto-approved", ending_at=access_request.request_ending_at + ) logger.info(f"Access request {access_request.id} to group {group.name} requires manual approval") From 6676330f83ca99af1142d6862f3f4191c4f19b30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 13:37:05 -0600 Subject: [PATCH 09/13] Bump cross-spawn from 7.0.3 to 7.0.6 (#203) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 64 +++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index b4586b59..634bc205 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,39 +9,39 @@ "version": "1.0.0", "dependencies": { "@mui/base": "^5.0.0-beta.28", - "@mui/icons-material": "latest", - "@mui/lab": "latest", - "@mui/material": "latest", - "@mui/styled-engine-sc": "latest", + "@mui/icons-material": "*", + "@mui/lab": "*", + "@mui/material": "*", + "@mui/styled-engine-sc": "*", "@mui/x-data-grid": "^6.18.4", - "@mui/x-date-pickers": "latest", - "@sentry/react": "latest", - "@tanstack/react-query": "latest", - "@testing-library/jest-dom": "latest", - "@testing-library/react": "latest", - "@testing-library/user-event": "latest", - "dayjs": "latest", - "env-cmd": "latest", - "react": "latest", - "react-dom": "latest", - "react-hook-form": "latest", - "react-hook-form-mui": "latest", - "react-router-dom": "latest", - "styled-components": "latest" + "@mui/x-date-pickers": "*", + "@sentry/react": "*", + "@tanstack/react-query": "*", + "@testing-library/jest-dom": "*", + "@testing-library/react": "*", + "@testing-library/user-event": "*", + "dayjs": "*", + "env-cmd": "*", + "react": "*", + "react-dom": "*", + "react-hook-form": "*", + "react-hook-form-mui": "*", + "react-router-dom": "*", + "styled-components": "*" }, "devDependencies": { - "@craco/craco": "latest", - "@sentry/cli": "latest", - "@types/node": "latest", - "@types/react": "latest", - "@types/react-dom": "latest", - "@types/styled-components": "latest", + "@craco/craco": "*", + "@sentry/cli": "*", + "@types/node": "*", + "@types/react": "*", + "@types/react-dom": "*", + "@types/styled-components": "*", "husky": "^8.0.3", "lint-staged": "^15.2.10", "prettier": "^3.2.5", "react-app-alias": "^2.2.2", - "react-scripts": "latest", - "typescript": "latest" + "react-scripts": "*", + "typescript": "*" } }, "node_modules/@adobe/css-tools": { @@ -7767,9 +7767,9 @@ "devOptional": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -27715,9 +27715,9 @@ "devOptional": true }, "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "requires": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", From b05099a6bbc2c5b02234ce1d156ef79f376cfb49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 13:37:36 -0600 Subject: [PATCH 10/13] Bump types-requests from 2.32.0.20240914 to 2.32.0.20241016 (#167) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c18ae2cd..21bf5d0c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -50,4 +50,4 @@ types-google-cloud-ndb==2.3.0.20240813 types-Flask-Cors==5.0.0.20240902 types-Flask-Migrate==4.0.0.20240311 types-python-dateutil==2.9.0.20240906 -types-requests==2.32.0.20240914 +types-requests==2.32.0.20241016 From 542586eac520ce53321ebd4bc33acab3190e8557 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 13:42:01 -0600 Subject: [PATCH 11/13] Bump types-python-dateutil from 2.9.0.20240906 to 2.9.0.20241003 (#166) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 21bf5d0c..79084b3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -49,5 +49,5 @@ mypy==1.13.0 types-google-cloud-ndb==2.3.0.20240813 types-Flask-Cors==5.0.0.20240902 types-Flask-Migrate==4.0.0.20240311 -types-python-dateutil==2.9.0.20240906 +types-python-dateutil==2.9.0.20241003 types-requests==2.32.0.20241016 From d1e38d728d3f4586288f638c612888618370926b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 13:42:18 -0600 Subject: [PATCH 12/13] Bump types-google-cloud-ndb from 2.3.0.20240813 to 2.3.0.20241103 (#193) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 79084b3a..8941666d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ tox==4.23.2 ruff==0.7.2 # Typing mypy==1.13.0 -types-google-cloud-ndb==2.3.0.20240813 +types-google-cloud-ndb==2.3.0.20241103 types-Flask-Cors==5.0.0.20240902 types-Flask-Migrate==4.0.0.20240311 types-python-dateutil==2.9.0.20241003 From 1c788368f0654a8fee73314a392b04f5054c5e84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 13:43:51 -0600 Subject: [PATCH 13/13] Bump the pip-minor group across 1 directory with 6 updates (#204) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8941666d..b52c2880 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # Flask click==8.1.7 -Flask==3.0.3 -Werkzeug==3.1.1 +Flask==3.1.0 +Werkzeug==3.1.3 gunicorn==23.0.0 six==1.16.0 python-dotenv==1.0.1 @@ -12,7 +12,7 @@ Flask-SQLAlchemy==3.1.1 SQLAlchemy==2.0.36 Flask-Migrate==4.0.7 sqlalchemy-json==0.7.0 -cloud-sql-python-connector==1.13.0 +cloud-sql-python-connector==1.14.0 pg8000==1.31.2 # API and Models @@ -25,14 +25,14 @@ flask-apispec==0.11.4 # Security Flask-Cors==5.0.0 flask-talisman==1.1.0 -PyJWT==2.9.0 +PyJWT==2.10.0 flask-oidc==2.2.2 # Requests requests==2.32.3 # Error Reporting -sentry-sdk[flask]==2.17.0 +sentry-sdk[flask]==2.19.0 # Okta okta==2.9.8 @@ -43,7 +43,7 @@ pluggy==1.5.0 # Test - TODO: Move test packages to separate file tox==4.23.2 # Lint -ruff==0.7.2 +ruff==0.8.0 # Typing mypy==1.13.0 types-google-cloud-ndb==2.3.0.20241103