diff --git a/README.md b/README.md index 5907d50e..da52ea0b 100644 --- a/README.md +++ b/README.md @@ -357,7 +357,9 @@ Plugins in Access follow the conventions defined by the [Python pluggy framework An example implementation of a notification plugin is included in [examples/plugins/notifications](https://github.com/discord/access/tree/main/examples/plugins/notifications), which can be extended to send messages using custom Python code. It implements the `NotificationPluginSpec` found in [notifications.py](https://github.com/discord/access/blob/main/api/plugins/notifications.py) -There's also an example implementation of a conditional access plugin in [examples/plugins/conditional_access](https://github.com/discord/access/tree/main/examples/plugins/conditional_access), which can be extended to conditionally approve or deny requests. It implements the `ConditionalAccessPluginSpec` found in [requests.py](https://github.com/discord/access/blob/main/api/plugins/conditional_access.py). +There's also an example implementation of a conditional access plugin in [examples/plugins/conditional_access](https://github.com/discord/access/tree/main/examples/plugins/conditional_access), which can be extended to conditionally approve or deny requests. It implements the `ConditionalAccessPluginSpec` found in [conditional_access.py](https://github.com/discord/access/blob/main/api/plugins/conditional_access.py). + +Audit events plugins can be created to stream access operations to external systems (SIEM, logging platforms, analytics). These plugins implement the `AuditEventsPluginSpec` found in [audit_events.py](https://github.com/discord/access/blob/main/api/plugins/audit_events.py). ### Installing a Plugin in the Docker Container diff --git a/api/operations/__init__.py b/api/operations/__init__.py index b099a0ce..64098eb5 100644 --- a/api/operations/__init__.py +++ b/api/operations/__init__.py @@ -1,22 +1,22 @@ 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_access_request import CreateAccessRequest from api.operations.create_app import CreateApp +from api.operations.create_group import CreateGroup +from api.operations.create_role_request import CreateRoleRequest from api.operations.create_tag import CreateTag from api.operations.delete_app import DeleteApp from api.operations.delete_group import DeleteGroup from api.operations.delete_tag import DeleteTag from api.operations.delete_user import DeleteUser -from api.operations.modify_groups_time_limit import ModifyGroupsTimeLimit from api.operations.modify_app_tags import ModifyAppTags from api.operations.modify_group_tags import ModifyGroupTags from api.operations.modify_group_type import ModifyGroupType from api.operations.modify_group_users import ModifyGroupUsers +from api.operations.modify_groups_time_limit import ModifyGroupsTimeLimit from api.operations.modify_role_groups import ModifyRoleGroups +from api.operations.reject_access_request import RejectAccessRequest +from api.operations.reject_role_request import RejectRoleRequest from api.operations.unmanage_group import UnmanageGroup __all__ = [ diff --git a/api/operations/approve_access_request.py b/api/operations/approve_access_request.py index 5f33da0c..7b67a73c 100644 --- a/api/operations/approve_access_request.py +++ b/api/operations/approve_access_request.py @@ -1,15 +1,16 @@ -from datetime import datetime +from datetime import datetime, timezone from typing import Optional - -from flask import current_app, has_request_context, request -from sqlalchemy.orm import joinedload, selectin_polymorphic +from uuid import uuid4 from api.extensions import db from api.models import AccessRequest, AccessRequestStatus, AppGroup, OktaGroup, OktaUser, RoleGroup from api.operations.constraints import CheckForReason from api.operations.modify_group_users import ModifyGroupUsers -from api.plugins import get_notification_hook +from api.plugins import get_audit_events_hook, get_notification_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy.orm import joinedload, selectin_polymorphic class ApproveAccessRequest: @@ -100,9 +101,11 @@ def execute(self) -> AccessRequest: { "event_type": EventType.access_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, + "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, @@ -131,6 +134,43 @@ def execute(self) -> AccessRequest: notify=self.notify, ).execute() + # Emit audit event to plugins (after DB commit) + try: + requester = db.session.get(OktaUser, self.access_request.requester_user_id) + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="access_approve", + timestamp=datetime.now(timezone.utc), + actor_id=self.approver_id or "system", + actor_email=self.approver_email, + target_type="access_request", + target_id=str(self.access_request.id), + target_name=f"Access request for {group.name}" if group else "Unknown group", + action="approved", + reason=self.approval_reason, + payload={ + "access_request_id": str(self.access_request.id), + "requester_user_id": self.access_request.requester_user_id, + "requester_email": requester.email if requester else None, + "requested_group_id": self.access_request.requested_group_id, + "requested_group_name": group.name if group else None, + "request_ownership": self.access_request.request_ownership, + "ending_at": self.ending_at.isoformat() if self.ending_at else None, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + # Now handled inside ModifyGroupUsers # self.access_request.approved_membership_id = ( # OktaUserGroupMember.query.filter( diff --git a/api/operations/approve_role_request.py b/api/operations/approve_role_request.py index 8966fb30..28aa0e17 100644 --- a/api/operations/approve_role_request.py +++ b/api/operations/approve_role_request.py @@ -1,15 +1,16 @@ -from datetime import datetime +from datetime import datetime, timezone from typing import Optional - -from flask import current_app, has_request_context, request -from sqlalchemy.orm import joinedload, selectin_polymorphic +from uuid import uuid4 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.plugins import get_audit_events_hook, get_notification_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy.orm import joinedload, selectin_polymorphic class ApproveRoleRequest: @@ -97,9 +98,11 @@ def execute(self) -> RoleRequest: { "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, + "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, @@ -109,6 +112,44 @@ def execute(self) -> RoleRequest: ) ) + # Emit audit event to plugins (after DB commit) + try: + requester = db.session.get(OktaUser, self.role_request.requester_user_id) + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="role_request_approve", + timestamp=datetime.now(timezone.utc), + actor_id=self.approver_id or "system", + actor_email=self.approver_email, + target_type="role_request", + target_id=str(self.role_request.id), + target_name=f"Role request for {group.name}" if group else "Unknown group", + action="approved", + reason=self.approval_reason, + payload={ + "role_request_id": str(self.role_request.id), + "requester_user_id": self.role_request.requester_user_id, + "requester_email": requester.email if requester else None, + "requester_role_id": self.role_request.requester_role_id, + "requested_group_id": self.role_request.requested_group_id, + "requested_group_name": group.name if group else None, + "request_ownership": self.role_request.request_ownership, + "ending_at": self.ending_at.isoformat() if self.ending_at else None, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + if self.role_request.request_ownership: ModifyRoleGroups( role_group=self.role_request.requester_role, diff --git a/api/operations/constraints/check_for_reason.py b/api/operations/constraints/check_for_reason.py index fab0ead5..a1831a4d 100644 --- a/api/operations/constraints/check_for_reason.py +++ b/api/operations/constraints/check_for_reason.py @@ -1,13 +1,9 @@ from typing import Optional, Tuple -from sqlalchemy.orm import ( - selectin_polymorphic, - selectinload, -) - from api.extensions import db from api.models import AppGroup, OktaGroup, OktaGroupTagMap, RoleGroup, RoleGroupMap, Tag from api.models.tag import coalesce_constraints +from sqlalchemy.orm import selectin_polymorphic, selectinload class CheckForReason: diff --git a/api/operations/constraints/check_for_self_add.py b/api/operations/constraints/check_for_self_add.py index 4c112fd1..683bca80 100644 --- a/api/operations/constraints/check_for_self_add.py +++ b/api/operations/constraints/check_for_self_add.py @@ -1,14 +1,10 @@ from typing import Optional, Tuple -from sqlalchemy.orm import ( - selectin_polymorphic, - selectinload, -) - from api.authorization import AuthorizationHelpers from api.extensions import db from api.models import AppGroup, OktaGroup, OktaGroupTagMap, OktaUser, OktaUserGroupMember, RoleGroup, RoleGroupMap, Tag from api.models.tag import coalesce_constraints +from sqlalchemy.orm import selectin_polymorphic, selectinload class CheckForSelfAdd: diff --git a/api/operations/create_access_request.py b/api/operations/create_access_request.py index 5ea6dae8..7083ba23 100644 --- a/api/operations/create_access_request.py +++ b/api/operations/create_access_request.py @@ -1,27 +1,20 @@ import random import string -from datetime import datetime +from datetime import datetime, timezone from typing import Optional - -from flask import current_app, has_request_context, request -from sqlalchemy.orm import joinedload, selectin_polymorphic, selectinload +from uuid import uuid4 from api.extensions import db -from api.models import ( - AccessRequest, - AccessRequestStatus, - AppGroup, - OktaGroup, - OktaGroupTagMap, - OktaUser, - RoleGroup, -) +from api.models import AccessRequest, AccessRequestStatus, AppGroup, OktaGroup, OktaGroupTagMap, OktaUser, RoleGroup 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_access_request import ApproveAccessRequest from api.operations.reject_access_request import RejectAccessRequest -from api.plugins import get_conditional_access_hook, get_notification_hook +from api.plugins import get_audit_events_hook, get_conditional_access_hook, get_notification_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy.orm import joinedload, selectin_polymorphic, selectinload class CreateAccessRequest: @@ -114,9 +107,11 @@ def execute(self) -> Optional[AccessRequest]: { "event_type": EventType.access_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, + "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, @@ -127,6 +122,42 @@ def execute(self) -> Optional[AccessRequest]: ) ) + # Emit audit event to plugins (after DB commit) + try: + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="access_create", + timestamp=datetime.now(timezone.utc), + actor_id=self.requester.id, + actor_email=self.requester.email, + target_type="access_request", + target_id=str(access_request.id), + target_name=f"Access request for {group.name}" if group else "Unknown group", + action="created", + reason=self.request_reason, + payload={ + "access_request_id": str(access_request.id), + "requester_user_id": self.requester.id, + "requester_email": self.requester.email, + "requested_group_id": self.requested_group.id, + "requested_group_name": group.name if group else None, + "request_ownership": self.request_ownership, + "request_ending_at": self.request_ending_at.isoformat() if self.request_ending_at else None, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + conditional_access_responses = self.conditional_access_hook.access_request_created( access_request=access_request, group=group, diff --git a/api/operations/create_app.py b/api/operations/create_app.py index 12a7eebf..4354b3f4 100644 --- a/api/operations/create_app.py +++ b/api/operations/create_app.py @@ -1,10 +1,8 @@ import random import string +from datetime import datetime, timezone from typing import Optional, TypedDict - -from flask import current_app, has_request_context, request -from sqlalchemy import func -from sqlalchemy.orm import with_polymorphic +from uuid import uuid4 from api.extensions import db from api.models import App, AppGroup, AppTagMap, OktaGroup, OktaGroupTagMap, OktaUser, RoleGroup, Tag @@ -13,7 +11,12 @@ from api.operations.modify_group_type import ModifyGroupType from api.operations.modify_group_users import ModifyGroupUsers from api.operations.modify_role_groups import ModifyRoleGroups +from api.plugins import get_audit_events_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy import func +from sqlalchemy.orm import with_polymorphic class AppDict(TypedDict): @@ -97,9 +100,11 @@ def execute(self) -> App: { "event_type": EventType.app_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, + "ip": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "app": self.app, @@ -242,7 +247,40 @@ def execute(self) -> App: ) db.session.commit() - return db.session.get(App, app_id) + # Emit audit event to plugins (after DB commit) + try: + created_app = db.session.get(App, app_id) + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="app_create", + timestamp=datetime.now(timezone.utc), + actor_id=self.current_user_id or "system", + actor_email=email, + target_type="app", + target_id=app_id, + target_name=self.app.name, + action="created", + reason="", + payload={ + "app_id": app_id, + "app_name": self.app.name, + "app_description": self.app.description, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + + return created_app # Generate a 20 character alphanumeric ID similar to Okta IDs for users and groups def __generate_id(self) -> str: diff --git a/api/operations/create_group.py b/api/operations/create_group.py index c1af9fc1..d133c586 100644 --- a/api/operations/create_group.py +++ b/api/operations/create_group.py @@ -1,13 +1,16 @@ +from datetime import datetime, timezone from typing import Optional, TypedDict, TypeVar - -from flask import current_app, has_request_context, request -from sqlalchemy import func -from sqlalchemy.orm import joinedload, selectin_polymorphic, with_polymorphic +from uuid import uuid4 from api.extensions import db from api.models import App, AppGroup, AppTagMap, OktaGroup, OktaGroupTagMap, OktaUser, RoleGroup, Tag +from api.plugins import get_audit_events_hook +from api.plugins.audit_events import AuditEventEnvelope from api.services import okta from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy import func +from sqlalchemy.orm import joinedload, selectin_polymorphic, with_polymorphic T = TypeVar("T", bound=OktaGroup) @@ -111,9 +114,11 @@ def execute(self, *, _group: Optional[T] = None) -> T: { "event_type": EventType.group_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, + "ip": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "group": group, @@ -121,4 +126,37 @@ def execute(self, *, _group: Optional[T] = None) -> T: ) ) + # Emit audit event to plugins (after DB commit) + try: + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="group_create", + timestamp=datetime.now(timezone.utc), + actor_id=self.current_user_id or "system", + actor_email=email, + target_type="group", + target_id=self.group.id, + target_name=self.group.name, + action="created", + reason="", + payload={ + "group_id": self.group.id, + "group_name": self.group.name, + "group_type": type(self.group).__name__, + "is_managed": self.group.is_managed, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + return self.group diff --git a/api/operations/create_role_request.py b/api/operations/create_role_request.py index 918ba7fd..96f07c32 100644 --- a/api/operations/create_role_request.py +++ b/api/operations/create_role_request.py @@ -1,10 +1,8 @@ import random import string -from datetime import datetime +from datetime import datetime, timezone from typing import Optional - -from flask import current_app, has_request_context, request -from sqlalchemy.orm import joinedload, selectin_polymorphic, selectinload +from uuid import uuid4 from api.extensions import db from api.models import ( @@ -23,8 +21,11 @@ from api.models.tag import coalesce_constraints 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.plugins import get_audit_events_hook, get_conditional_access_hook, get_notification_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy.orm import joinedload, selectin_polymorphic, selectinload class CreateRoleRequest: @@ -172,9 +173,11 @@ def execute(self) -> Optional[RoleRequest]: { "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, + "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, @@ -185,6 +188,43 @@ def execute(self) -> Optional[RoleRequest]: ) ) + # Emit audit event to plugins (after DB commit) + try: + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="role_request_create", + timestamp=datetime.now(timezone.utc), + actor_id=self.requester.id, + actor_email=self.requester.email, + target_type="role_request", + target_id=str(role_request.id), + target_name=f"Role request for {group.name}" if group else "Unknown group", + action="created", + reason=self.request_reason, + payload={ + "role_request_id": str(role_request.id), + "requester_user_id": self.requester.id, + "requester_email": self.requester.email, + "requester_role_id": self.requester_role.id, + "requested_group_id": self.requested_group.id, + "requested_group_name": group.name if group else None, + "request_ownership": self.request_ownership, + "request_ending_at": self.request_ending_at.isoformat() if self.request_ending_at else None, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + conditional_access_responses = self.conditional_access_hook.role_request_created( role_request=role_request, role=self.requester_role, diff --git a/api/operations/create_tag.py b/api/operations/create_tag.py index 730d4de1..df87b9cf 100644 --- a/api/operations/create_tag.py +++ b/api/operations/create_tag.py @@ -1,13 +1,16 @@ import random import string +from datetime import datetime, timezone from typing import Any, Optional, TypedDict - -from flask import current_app, has_request_context, request -from sqlalchemy import func +from uuid import uuid4 from api.extensions import db from api.models import OktaUser, Tag +from api.plugins import get_audit_events_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy import func class TagDict(TypedDict): @@ -54,9 +57,11 @@ def execute(self) -> Tag: { "event_type": EventType.tag_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, + "ip": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "tag": self.tag, @@ -64,6 +69,38 @@ def execute(self) -> Tag: ) ) + # Emit audit event to plugins (after DB commit) + try: + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="tag_create", + timestamp=datetime.now(timezone.utc), + actor_id=self.current_user_id or "system", + actor_email=email, + target_type="tag", + target_id=str(self.tag.id), + target_name=self.tag.name, + action="created", + reason="", + payload={ + "tag_id": str(self.tag.id), + "tag_name": self.tag.name, + "tag_description": self.tag.description, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + return self.tag # Generate a 20 character alphanumeric ID similar to Okta IDs for users and groups diff --git a/api/operations/delete_app.py b/api/operations/delete_app.py index 71b0da74..5dfad952 100644 --- a/api/operations/delete_app.py +++ b/api/operations/delete_app.py @@ -1,11 +1,14 @@ +from datetime import datetime, timezone from typing import Optional - -from flask import current_app, has_request_context, request +from uuid import uuid4 from api.extensions import db from api.models import App, AppGroup, AppTagMap, OktaUser from api.operations.delete_group import DeleteGroup +from api.plugins import get_audit_events_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request class DeleteApp: @@ -38,9 +41,11 @@ def execute(self) -> None: { "event_type": EventType.app_delete, "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, + "ip": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "app": self.app, @@ -48,9 +53,44 @@ def execute(self) -> None: ) ) + # Store app details before deletion for audit event + app_id = self.app.id + app_name = self.app.name + self.app.deleted_at = db.func.now() db.session.commit() + # Emit audit event to plugins (after DB commit) + try: + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="app_delete", + timestamp=datetime.now(timezone.utc), + actor_id=self.current_user_id or "system", + actor_email=email, + target_type="app", + target_id=str(app_id), + target_name=app_name, + action="deleted", + reason="", + payload={ + "app_id": str(app_id), + "app_name": app_name, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + # Delete all associated Okta App Groups and end their membership app_groups = AppGroup.query.filter(AppGroup.deleted_at.is_(None)).filter(AppGroup.app_id == self.app.id) app_group_ids = [ag.id for ag in app_groups] diff --git a/api/operations/delete_group.py b/api/operations/delete_group.py index c20d8d0c..92c2f7cd 100644 --- a/api/operations/delete_group.py +++ b/api/operations/delete_group.py @@ -1,8 +1,7 @@ import asyncio +from datetime import datetime, timezone from typing import Optional - -from flask import current_app, has_request_context, request -from sqlalchemy.orm import joinedload, selectin_polymorphic +from uuid import uuid4 from api.extensions import db from api.models import ( @@ -18,8 +17,12 @@ RoleGroupMap, ) from api.operations.reject_access_request import RejectAccessRequest +from api.plugins import get_audit_events_hook +from api.plugins.audit_events import AuditEventEnvelope from api.services import okta from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy.orm import joinedload, selectin_polymorphic class DeleteGroup: @@ -65,9 +68,11 @@ async def _execute(self) -> None: { "event_type": EventType.group_delete, "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, + "ip": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "group": self.group, @@ -234,5 +239,43 @@ async def _execute(self) -> None: ) db.session.commit() + # Emit audit event to plugins (after DB commit) + try: + email = None + if self.current_user_id: + actor_user = db.session.get(OktaUser, self.current_user_id) + email = actor_user.email if actor_user else None + + context = has_request_context() + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="group_delete", + timestamp=datetime.now(timezone.utc), + actor_id=self.current_user_id or "system", + actor_email=email, + target_type="group", + target_id=self.group.id, + target_name=self.group.name, + action="deleted", + reason="", + payload={ + "group_id": self.group.id, + "group_name": self.group.name, + "group_type": type(self.group).__name__, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + if len(okta_tasks) > 0: await asyncio.wait(okta_tasks) diff --git a/api/operations/delete_tag.py b/api/operations/delete_tag.py index 83e8b8b4..1996eab9 100644 --- a/api/operations/delete_tag.py +++ b/api/operations/delete_tag.py @@ -1,10 +1,13 @@ +from datetime import datetime, timezone from typing import Optional - -from flask import current_app, has_request_context, request +from uuid import uuid4 from api.extensions import db from api.models import AppTagMap, OktaGroupTagMap, OktaUser, Tag +from api.plugins import get_audit_events_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request class DeleteTag: @@ -29,9 +32,11 @@ def execute(self) -> None: { "event_type": EventType.tag_delete, "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, + "ip": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "tag": self.tag, @@ -64,3 +69,34 @@ def execute(self) -> None: ) db.session.commit() + + # Emit audit event to plugins (after DB commit) + try: + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="tag_delete", + timestamp=datetime.now(timezone.utc), + actor_id=self.current_user_id or "system", + actor_email=email, + target_type="tag", + target_id=str(self.tag.id), + target_name=self.tag.name, + action="deleted", + reason="", + payload={ + "tag_id": str(self.tag.id), + "tag_name": self.tag.name, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) diff --git a/api/operations/delete_user.py b/api/operations/delete_user.py index 6d2515d7..8f76db35 100644 --- a/api/operations/delete_user.py +++ b/api/operations/delete_user.py @@ -1,14 +1,11 @@ import asyncio from typing import Optional -from sqlalchemy.orm import ( - joinedload, -) - from api.extensions import db from api.models import AccessRequest, AccessRequestStatus, OktaGroup, OktaUser, OktaUserGroupMember -from api.operations import RejectAccessRequest +from api.operations.reject_access_request import RejectAccessRequest from api.services import okta +from sqlalchemy.orm import joinedload class DeleteUser: diff --git a/api/operations/modify_app_tags.py b/api/operations/modify_app_tags.py index 3c98bb0c..8622b2ee 100644 --- a/api/operations/modify_app_tags.py +++ b/api/operations/modify_app_tags.py @@ -1,11 +1,14 @@ +from datetime import datetime, timezone from typing import Optional - -from flask import current_app, has_request_context, request +from uuid import uuid4 from api.extensions import db from api.models import App, AppGroup, AppTagMap, OktaGroupTagMap, OktaUser, Tag -from api.operations import ModifyGroupsTimeLimit +from api.operations.modify_groups_time_limit import ModifyGroupsTimeLimit +from api.plugins import get_audit_events_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request class ModifyAppTags: @@ -139,11 +142,13 @@ def execute(self) -> App: { "event_type": EventType.app_modify_tags, "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, + "ip": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "app": self.app, @@ -153,4 +158,40 @@ def execute(self) -> App: ) ) + # Emit audit event to plugins (after DB commit) + if len(self.tags_to_add) > 0 or len(self.tags_to_remove) > 0: + try: + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="tag_update", + timestamp=datetime.now(timezone.utc), + actor_id=self.current_user_id or "system", + actor_email=email, + target_type="app", + target_id=self.app.id, + target_name=self.app.name, + action="tags_modified", + reason="", + payload={ + "app_id": self.app.id, + "app_name": self.app.name, + "tags_added": [t.name for t in self.tags_to_add], + "tags_removed": [t.name for t in self.tags_to_remove], + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + return self.app diff --git a/api/operations/modify_group_tags.py b/api/operations/modify_group_tags.py index 23a26fd2..2ad466b7 100644 --- a/api/operations/modify_group_tags.py +++ b/api/operations/modify_group_tags.py @@ -1,12 +1,15 @@ +from datetime import datetime, timezone from typing import Optional - -from flask import current_app, has_request_context, request -from sqlalchemy.orm import joinedload, selectin_polymorphic +from uuid import uuid4 from api.extensions import db from api.models import AppGroup, OktaGroup, OktaGroupTagMap, OktaUser, RoleGroup, Tag -from api.operations import ModifyGroupsTimeLimit +from api.operations.modify_groups_time_limit import ModifyGroupsTimeLimit +from api.plugins import get_audit_events_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy.orm import joinedload, selectin_polymorphic class ModifyGroupTags: @@ -112,11 +115,13 @@ def execute(self) -> OktaGroup: { "event_type": EventType.group_modify_tags, "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, + "ip": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "group": group, @@ -126,4 +131,40 @@ def execute(self) -> OktaGroup: ) ) + # Emit audit event to plugins (after DB commit) + if len(self.tags_to_add) > 0 or len(self.tags_to_remove) > 0: + try: + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="tag_update", + timestamp=datetime.now(timezone.utc), + actor_id=self.current_user_id or "system", + actor_email=email, + target_type="group", + target_id=self.group.id, + target_name=self.group.name, + action="tags_modified", + reason="", + payload={ + "group_id": self.group.id, + "group_name": self.group.name, + "tags_added": [t.name for t in self.tags_to_add], + "tags_removed": [t.name for t in self.tags_to_remove], + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + return self.group diff --git a/api/operations/modify_group_type.py b/api/operations/modify_group_type.py index c1199429..756f6eea 100644 --- a/api/operations/modify_group_type.py +++ b/api/operations/modify_group_type.py @@ -1,9 +1,5 @@ from typing import Optional -from flask import current_app, has_request_context, request -from sqlalchemy import delete, insert -from sqlalchemy.orm import joinedload, selectin_polymorphic - from api.extensions import db from api.models import ( AppGroup, @@ -19,6 +15,9 @@ from api.operations.modify_groups_time_limit import ModifyGroupsTimeLimit from api.operations.modify_role_groups import ModifyRoleGroups from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy import delete, insert +from sqlalchemy.orm import joinedload, selectin_polymorphic class ModifyGroupType: @@ -218,11 +217,13 @@ def execute(self) -> OktaGroup: { "event_type": EventType.group_modify_type, "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, + "ip": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "group": self.group, diff --git a/api/operations/modify_group_users.py b/api/operations/modify_group_users.py index 10871818..b1a50b5f 100644 --- a/api/operations/modify_group_users.py +++ b/api/operations/modify_group_users.py @@ -1,9 +1,7 @@ import asyncio from datetime import UTC, datetime from typing import Dict, Optional - -from flask import current_app, has_request_context, request -from sqlalchemy.orm import joinedload, selectin_polymorphic, selectinload +from uuid import uuid4 from api.extensions import db from api.models import ( @@ -21,9 +19,12 @@ from api.models.access_request import get_all_possible_request_approvers from api.models.tag import coalesce_ended_at from api.operations.constraints import CheckForReason, CheckForSelfAdd -from api.plugins import get_notification_hook +from api.plugins import get_audit_events_hook, get_notification_hook +from api.plugins.audit_events import AuditEventEnvelope from api.services import okta from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy.orm import joinedload, selectin_polymorphic, selectinload class ModifyGroupUsers: @@ -172,9 +173,11 @@ async def _execute(self) -> OktaGroup: { "event_type": EventType.group_modify_users, "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, + "ip": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "group": self.group, @@ -565,6 +568,150 @@ async def _execute(self) -> OktaGroup: if len(async_tasks) > 0: await asyncio.wait(async_tasks) + # Emit audit events to plugins (after DB commit) - 4 event types + try: + audit_hook = get_audit_events_hook() + context = has_request_context() + + # Get actor email for audit envelope + actor_email = None + if self.current_user_id: + actor_user = db.session.get(OktaUser, self.current_user_id) + actor_email = actor_user.email if actor_user else None + + # Event 1: group_member_added + for member in self.members_to_add: + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="group_member_added", + timestamp=datetime.now(UTC), + actor_id=self.current_user_id or "system", + actor_email=actor_email, + target_type="group_membership", + target_id=self.group.id, + target_name=self.group.name, + action="member_added", + reason=self.created_reason, + payload={ + "group_id": self.group.id, + "group_name": self.group.name, + "user_id": member.id, + "user_email": member.email, + "ended_at": self.members_added_ended_at.isoformat() if self.members_added_ended_at else None, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + + # Event 2: group_member_removed + for member in self.members_to_remove: + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="group_member_removed", + timestamp=datetime.now(UTC), + actor_id=self.current_user_id or "system", + actor_email=actor_email, + target_type="group_membership", + target_id=self.group.id, + target_name=self.group.name, + action="member_removed", + reason=self.created_reason, + payload={ + "group_id": self.group.id, + "group_name": self.group.name, + "user_id": member.id, + "user_email": member.email, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + + # Event 3: group_owner_added + for owner in self.owners_to_add: + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="group_owner_added", + timestamp=datetime.now(UTC), + actor_id=self.current_user_id or "system", + actor_email=actor_email, + target_type="group_ownership", + target_id=self.group.id, + target_name=self.group.name, + action="owner_added", + reason=self.created_reason, + payload={ + "group_id": self.group.id, + "group_name": self.group.name, + "user_id": owner.id, + "user_email": owner.email, + "ended_at": self.owners_added_ended_at.isoformat() if self.owners_added_ended_at else None, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + + # Event 4: group_owner_removed + for owner in self.owners_to_remove: + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="group_owner_removed", + timestamp=datetime.now(UTC), + actor_id=self.current_user_id or "system", + actor_email=actor_email, + target_type="group_ownership", + target_id=self.group.id, + target_name=self.group.name, + action="owner_removed", + reason=self.created_reason, + payload={ + "group_id": self.group.id, + "group_name": self.group.name, + "user_id": owner.id, + "user_email": owner.email, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + return self.group def _approve_access_request( diff --git a/api/operations/modify_role_groups.py b/api/operations/modify_role_groups.py index 5199e425..6087a604 100644 --- a/api/operations/modify_role_groups.py +++ b/api/operations/modify_role_groups.py @@ -1,9 +1,7 @@ import asyncio from datetime import UTC, datetime from typing import Dict, Optional - -from flask import current_app, has_request_context, request -from sqlalchemy.orm import selectinload +from uuid import uuid4 from api.extensions import db from api.models import ( @@ -21,9 +19,12 @@ from api.models.access_request import get_all_possible_request_approvers from api.models.tag import coalesce_ended_at from api.operations.constraints import CheckForReason, CheckForSelfAdd -from api.plugins import get_notification_hook +from api.plugins import get_audit_events_hook, get_notification_hook +from api.plugins.audit_events import AuditEventEnvelope from api.services import okta from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy.orm import selectinload class ModifyRoleGroups: @@ -171,9 +172,11 @@ async def _execute(self) -> RoleGroup: { "event_type": EventType.role_group_modify, "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, + "ip": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), "current_user_id": self.current_user_id, "current_user_email": email, "role": self.role, @@ -499,6 +502,151 @@ async def _execute(self) -> RoleGroup: if len(async_tasks) > 0: await asyncio.wait(async_tasks) + # Emit audit events to plugins (after DB commit) - 2 event types + try: + audit_hook = get_audit_events_hook() + + # Get actor email for audit envelope + actor_email = None + if self.current_user_id: + actor_user = db.session.get(OktaUser, self.current_user_id) + actor_email = actor_user.email if actor_user else None + + # Event 1: role_group_assigned + for group in self.groups_to_add: + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="role_group_assigned", + timestamp=datetime.now(UTC), + actor_id=self.current_user_id or "system", + actor_email=actor_email, + target_type="role_assignment", + target_id=self.role.id, + target_name=self.role.name, + action="role_assigned", + reason=self.created_reason, + payload={ + "role_group_id": self.role.id, + "role_group_name": self.role.name, + "assigned_group_id": group.id, + "assigned_group_name": group.name, + "ended_at": self.groups_added_ended_at.isoformat() if self.groups_added_ended_at else None, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + + # Also emit for owner groups added + for group in self.owner_groups_to_add: + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="role_group_assigned", + timestamp=datetime.now(UTC), + actor_id=self.current_user_id or "system", + actor_email=actor_email, + target_type="role_assignment", + target_id=self.role.id, + target_name=self.role.name, + action="role_assigned_owner", + reason=self.created_reason, + payload={ + "role_group_id": self.role.id, + "role_group_name": self.role.name, + "assigned_group_id": group.id, + "assigned_group_name": group.name, + "is_owner": True, + "ended_at": self.groups_added_ended_at.isoformat() if self.groups_added_ended_at else None, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + + # Event 2: role_group_unassigned + for group in self.groups_to_remove: + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="role_group_unassigned", + timestamp=datetime.now(UTC), + actor_id=self.current_user_id or "system", + actor_email=actor_email, + target_type="role_assignment", + target_id=self.role.id, + target_name=self.role.name, + action="role_unassigned", + reason=self.created_reason, + payload={ + "role_group_id": self.role.id, + "role_group_name": self.role.name, + "unassigned_group_id": group.id, + "unassigned_group_name": group.name, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + + # Also emit for owner groups removed + for group in self.owner_groups_to_remove: + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="role_group_unassigned", + timestamp=datetime.now(UTC), + actor_id=self.current_user_id or "system", + actor_email=actor_email, + target_type="role_assignment", + target_id=self.role.id, + target_name=self.role.name, + action="role_unassigned_owner", + reason=self.created_reason, + payload={ + "role_group_id": self.role.id, + "role_group_name": self.role.name, + "unassigned_group_id": group.id, + "unassigned_group_name": group.name, + "is_owner": True, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get( + "X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr) + ) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + return self.role def __remove_groups_from_role(self, groups_to_remove: list[OktaGroup] = [], owner_groups: bool = False) -> None: diff --git a/api/operations/reject_access_request.py b/api/operations/reject_access_request.py index 8d88bce4..91fe2683 100644 --- a/api/operations/reject_access_request.py +++ b/api/operations/reject_access_request.py @@ -1,14 +1,16 @@ +from datetime import datetime, timezone 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 uuid import uuid4 from api.extensions import db from api.models import AccessRequest, AccessRequestStatus, AppGroup, OktaGroup, OktaUser, RoleGroup from api.models.access_request import get_all_possible_request_approvers -from api.plugins import get_notification_hook +from api.plugins import get_audit_events_hook, get_notification_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy import nullsfirst +from sqlalchemy.orm import joinedload, selectin_polymorphic class RejectAccessRequest: @@ -75,9 +77,11 @@ def execute(self) -> AccessRequest: { "event_type": EventType.access_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, + "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, @@ -87,6 +91,42 @@ def execute(self) -> AccessRequest: ) ) + # Emit audit event to plugins (after DB commit) + try: + requester = db.session.get(OktaUser, self.access_request.requester_user_id) + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="access_reject", + timestamp=datetime.now(timezone.utc), + actor_id=self.rejecter_id or "system", + actor_email=email, + target_type="access_request", + target_id=str(self.access_request.id), + target_name=f"Access request for {group.name}" if group else "Unknown group", + action="rejected", + reason=self.rejection_reason, + payload={ + "access_request_id": str(self.access_request.id), + "requester_user_id": self.access_request.requester_user_id, + "requester_email": requester.email if requester else None, + "requested_group_id": self.access_request.requested_group_id, + "requested_group_name": group.name if group else None, + "request_ownership": self.access_request.request_ownership, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + if self.notify: requester = db.session.get(OktaUser, self.access_request.requester_user_id) diff --git a/api/operations/reject_role_request.py b/api/operations/reject_role_request.py index b2f1535f..560a18f0 100644 --- a/api/operations/reject_role_request.py +++ b/api/operations/reject_role_request.py @@ -1,14 +1,16 @@ +from datetime import datetime, timezone 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 uuid import uuid4 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.plugins import get_audit_events_hook, get_notification_hook +from api.plugins.audit_events import AuditEventEnvelope from api.views.schemas import AuditLogSchema, EventType +from flask import current_app, has_request_context, request +from sqlalchemy import nullsfirst +from sqlalchemy.orm import joinedload, selectin_polymorphic class RejectRoleRequest: @@ -75,9 +77,11 @@ def execute(self) -> RoleRequest: { "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, + "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, @@ -87,6 +91,43 @@ def execute(self) -> RoleRequest: ) ) + # Emit audit event to plugins (after DB commit) + try: + requester = db.session.get(OktaUser, self.role_request.requester_user_id) + audit_hook = get_audit_events_hook() + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="role_request_reject", + timestamp=datetime.now(timezone.utc), + actor_id=self.rejecter_id or "system", + actor_email=email, + target_type="role_request", + target_id=str(self.role_request.id), + target_name=f"Role request for {group.name}" if group else "Unknown group", + action="rejected", + reason=self.rejection_reason, + payload={ + "role_request_id": str(self.role_request.id), + "requester_user_id": self.role_request.requester_user_id, + "requester_email": requester.email if requester else None, + "requester_role_id": self.role_request.requester_role_id, + "requested_group_id": self.role_request.requested_group_id, + "requested_group_name": group.name if group else None, + "request_ownership": self.role_request.request_ownership, + }, + metadata={ + "user_agent": request.headers.get("User-Agent") if context else None, + "ip_address": ( + request.headers.get("X-Forwarded-For", request.headers.get("X-Real-IP", request.remote_addr)) + if context + else None + ), + }, + ) + audit_hook.audit_event_logged(envelope=envelope) + except Exception as e: + current_app.logger.error(f"Failed to emit audit event: {e}", exc_info=True) + 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) diff --git a/api/operations/unmanage_group.py b/api/operations/unmanage_group.py index 9ab5a2b5..d47daeb9 100644 --- a/api/operations/unmanage_group.py +++ b/api/operations/unmanage_group.py @@ -1,8 +1,6 @@ import logging from typing import Optional -from sqlalchemy.orm import selectin_polymorphic - from api.extensions import db from api.models import ( AccessRequest, @@ -15,6 +13,7 @@ RoleGroupMap, ) from api.operations.reject_access_request import RejectAccessRequest +from sqlalchemy.orm import selectin_polymorphic logger = logging.getLogger(__name__) @@ -94,8 +93,7 @@ def execute(self, dry_run: bool = False) -> None: ) for obsolete_access_request in obsolete_access_requests: logger.info( - f"Rejecting obsolete access request {obsolete_access_request.id} " - f"for unmanaged group {self.group.id}" + f"Rejecting obsolete access request {obsolete_access_request.id} for unmanaged group {self.group.id}" ) if not dry_run: RejectAccessRequest( diff --git a/api/plugins/__init__.py b/api/plugins/__init__.py index 2e89399b..028ecadc 100644 --- a/api/plugins/__init__.py +++ b/api/plugins/__init__.py @@ -1,14 +1,18 @@ import pluggy +from api.plugins.audit_events import get_audit_events_hook from api.plugins.conditional_access import ConditionalAccessResponse, get_conditional_access_hook from api.plugins.notifications import get_notification_hook condtional_access_hook_impl = pluggy.HookimplMarker("access_conditional_access") notification_hook_impl = pluggy.HookimplMarker("access_notifications") +audit_events_hook_impl = pluggy.HookimplMarker("access_audit_events") __all__ = [ "ConditionalAccessResponse", - "conditional_access_hook_impl", + "audit_events_hook_impl", + "condtional_access_hook_impl", + "get_audit_events_hook", "get_conditional_access_hook", "get_notification_hook", "notification_hook_impl", diff --git a/api/plugins/audit_events.py b/api/plugins/audit_events.py new file mode 100644 index 00000000..564d016a --- /dev/null +++ b/api/plugins/audit_events.py @@ -0,0 +1,106 @@ +"""Audit event notification system for plugins. + +This module defines the pluggy hookspec for audit event streaming. Access operations +call this hook after committing audit data to the database, allowing plugins to +capture and process audit events (e.g., stream to SIEM systems). + +Constitution Principle III: All audit events must include WHO/WHAT/TARGET/WHEN/WHY context. +""" + +import logging +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Dict, Optional +from uuid import UUID + +import pluggy + +audit_events_plugin_name = "access_audit_events" +hookspec = pluggy.HookspecMarker(audit_events_plugin_name) +hookimpl = pluggy.HookimplMarker(audit_events_plugin_name) + +_cached_audit_events_hook: Optional[pluggy.HookRelay] = None + +logger = logging.getLogger(__name__) + + +@dataclass +class AuditEventEnvelope: + """Canonical structure for audit events passed to plugins. + + This envelope contains complete WHO/WHAT/TARGET/WHEN/WHY context required + by Constitution Principle III, plus metadata for plugin processing. + + Attributes: + id: Unique event identifier (UUID v4) for deduplication across retries + event_type: Access event type (e.g., 'access_request.created', 'group.member_added') + timestamp: When the audited action occurred (timezone-aware UTC datetime) + actor_id: WHO - User ID of the person performing the action + actor_email: WHO - Email of the actor for human traceability + target_type: WHAT - Type of resource affected (group, app, role, tag, access_request) + target_id: WHAT - ID of the affected resource + target_name: WHAT - Human-readable name of the affected resource + action: WHAT - Specific action performed (created, approved, deleted, etc.) + reason: WHY - User-provided justification for the action (if applicable) + payload: Complete audit data including all context from database models + metadata: Additional context (request_id, session_id, ip_address, etc.) + """ + + id: UUID + event_type: str + timestamp: datetime + actor_id: str + actor_email: Optional[str] + target_type: str + target_id: str + target_name: Optional[str] + action: str + reason: Optional[str] + payload: Dict[str, Any] + metadata: Dict[str, Any] + + +class AuditEventsPluginSpec: + """Pluggy hookspec for audit event notifications.""" + + @hookspec + def audit_event_logged(self, envelope: AuditEventEnvelope) -> None: + """Called immediately after Access commits audit data to the database. + + This hook is invoked synchronously after every state-changing operation + that generates an audit event. Plugins receive the complete audit context + and can process it (e.g., stream to SIEM, trigger alerts, update metrics). + + Args: + envelope: Complete audit event with WHO/WHAT/TARGET/WHEN/WHY context + + Important: + - Hook is called AFTER database commit (zero data loss guarantee) + - Hook execution is synchronous (blocks request until complete) + - Plugins MUST handle errors gracefully to avoid breaking Access operations + - Plugins SHOULD complete quickly (<100ms) to avoid request latency impact + - Plugins MUST NOT modify the envelope (read-only) + """ + + +def get_audit_events_hook() -> pluggy.HookRelay: + """Get the audit events hook relay for calling plugins. + + Returns: + HookRelay configured with audit events plugin spec + + Usage: + hook = get_audit_events_hook() + hook.audit_event_logged(envelope=event) + """ + global _cached_audit_events_hook + + if _cached_audit_events_hook is None: + pm = pluggy.PluginManager(audit_events_plugin_name) + pm.add_hookspecs(AuditEventsPluginSpec) + count = pm.load_setuptools_entrypoints(audit_events_plugin_name) + logger.info(f"Initialized {audit_events_plugin_name} plugin manager") + logger.info(f"Count of loaded audit events plugins: {count}") + _cached_audit_events_hook = pm.hook + + return _cached_audit_events_hook diff --git a/api/views/schemas/core_schemas.py b/api/views/schemas/core_schemas.py index 20733e44..01c4affa 100644 --- a/api/views/schemas/core_schemas.py +++ b/api/views/schemas/core_schemas.py @@ -320,7 +320,8 @@ class OktaGroupSchema(SQLAlchemyAutoSchema): "user.first_name", "user.last_name", "user.display_name", - "user.deleted_at" "role_group_mapping.created_at", + "user.deleted_at", + "role_group_mapping.created_at", "role_group_mapping.ended_at", "role_group_mapping.role_group.id", "role_group_mapping.role_group.type", diff --git a/tests/test_audit_events.py b/tests/test_audit_events.py new file mode 100644 index 00000000..21eb3a87 --- /dev/null +++ b/tests/test_audit_events.py @@ -0,0 +1,233 @@ +"""Tests for audit events plugin core functionality.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest + +from api.plugins.audit_events import AuditEventEnvelope, get_audit_events_hook + + +@pytest.fixture +def sample_envelope() -> AuditEventEnvelope: + """Create a sample audit event envelope for testing.""" + return AuditEventEnvelope( + id=uuid4(), + event_type="access_request.created", + timestamp=datetime.now(timezone.utc), + actor_id="user-123", + actor_email="user@example.com", + target_type="access_request", + target_id="req-123", + target_name="Test Access Request", + action="created", + reason="Testing purposes", + payload={"test": "data"}, + metadata={"ip_address": "10.0.1.100"}, + ) + + +class TestAuditEventEnvelope: + """Tests for AuditEventEnvelope dataclass.""" + + def test_envelope_creation(self, sample_envelope: AuditEventEnvelope) -> None: + """Test AuditEventEnvelope can be instantiated with all required fields.""" + assert sample_envelope.event_type == "access_request.created" + assert sample_envelope.actor_id == "user-123" + assert sample_envelope.actor_email == "user@example.com" + assert sample_envelope.target_type == "access_request" + assert sample_envelope.target_id == "req-123" + assert sample_envelope.target_name == "Test Access Request" + assert sample_envelope.action == "created" + assert sample_envelope.reason == "Testing purposes" + assert sample_envelope.payload == {"test": "data"} + assert sample_envelope.metadata == {"ip_address": "10.0.1.100"} + + def test_envelope_with_optional_fields_none(self) -> None: + """Test envelope can be created with optional fields as None.""" + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="app_delete", + timestamp=datetime.now(timezone.utc), + actor_id="system", + actor_email=None, # Optional field + target_type="app", + target_id="app-456", + target_name=None, # Optional field + action="deleted", + reason=None, # Optional field + payload={}, + metadata={}, + ) + assert envelope.actor_email is None + assert envelope.target_name is None + assert envelope.reason is None + + def test_envelope_id_is_uuid(self, sample_envelope: AuditEventEnvelope) -> None: + """Test envelope ID is a valid UUID.""" + assert isinstance(sample_envelope.id, type(uuid4())) + assert str(sample_envelope.id) # Can be converted to string + + def test_envelope_timestamp_is_datetime(self, sample_envelope: AuditEventEnvelope) -> None: + """Test envelope timestamp is a datetime object.""" + assert isinstance(sample_envelope.timestamp, datetime) + assert sample_envelope.timestamp.tzinfo is not None # Should be timezone-aware + + +class TestGetAuditEventsHook: + """Tests for get_audit_events_hook function.""" + + def test_get_audit_events_hook_returns_hook_relay(self) -> None: + """Test hook initialization returns a HookRelay object.""" + hook = get_audit_events_hook() + assert hook is not None + assert hasattr(hook, "audit_event_logged") + + def test_get_audit_events_hook_is_singleton(self) -> None: + """Test hook is cached and returns the same instance on subsequent calls.""" + hook1 = get_audit_events_hook() + hook2 = get_audit_events_hook() + assert hook1 is hook2 + + def test_hook_invocation_with_no_plugins_succeeds(self, sample_envelope: AuditEventEnvelope) -> None: + """Test hook can be called successfully even when no plugins are registered.""" + hook = get_audit_events_hook() + # Should not raise an exception even if no plugins are listening + hook.audit_event_logged(envelope=sample_envelope) + + def test_hook_invocation_with_multiple_envelopes(self) -> None: + """Test hook can be called multiple times with different envelopes.""" + hook = get_audit_events_hook() + + envelope1 = AuditEventEnvelope( + id=uuid4(), + event_type="access_approve", + timestamp=datetime.now(timezone.utc), + actor_id="approver-1", + actor_email="approver@example.com", + target_type="access_request", + target_id="req-1", + target_name="Request 1", + action="approved", + reason="Approved for deployment", + payload={}, + metadata={}, + ) + + envelope2 = AuditEventEnvelope( + id=uuid4(), + event_type="group_member_added", + timestamp=datetime.now(timezone.utc), + actor_id="admin-1", + actor_email="admin@example.com", + target_type="group", + target_id="group-1", + target_name="Test Group", + action="member_added", + reason="Adding team member", + payload={}, + metadata={}, + ) + + # Both should succeed without error + hook.audit_event_logged(envelope=envelope1) + hook.audit_event_logged(envelope=envelope2) + + +class TestAuditEventTypes: + """Tests for various audit event types to ensure envelope structure supports all types.""" + + def test_access_request_event(self) -> None: + """Test envelope for access request events.""" + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="access_create", + timestamp=datetime.now(timezone.utc), + actor_id="requester-123", + actor_email="requester@example.com", + target_type="access_request", + target_id="req-789", + target_name="Production API Access", + action="created", + reason="Need access for deployment", + payload={"app_group_id": "app-123"}, + metadata={}, + ) + assert envelope.event_type == "access_create" + assert envelope.target_type == "access_request" + + def test_group_management_event(self) -> None: + """Test envelope for group management events.""" + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="group_member_added", + timestamp=datetime.now(timezone.utc), + actor_id="admin-456", + actor_email="admin@example.com", + target_type="group_membership", + target_id="group-456", + target_name="DevOps Team", + action="member_added", + reason="New team member onboarding", + payload={"member_id": "user-789"}, + metadata={}, + ) + assert envelope.event_type == "group_member_added" + assert envelope.target_type == "group_membership" + + def test_app_management_event(self) -> None: + """Test envelope for app management events.""" + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="app_create", + timestamp=datetime.now(timezone.utc), + actor_id="admin-123", + actor_email="admin@example.com", + target_type="app", + target_id="app-new-123", + target_name="New Production App", + action="created", + reason="New application setup", + payload={"description": "Production application"}, + metadata={}, + ) + assert envelope.event_type == "app_create" + assert envelope.target_type == "app" + + def test_role_request_event(self) -> None: + """Test envelope for role request events.""" + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="role_request_approve", + timestamp=datetime.now(timezone.utc), + actor_id="approver-456", + actor_email="approver@example.com", + target_type="role_request", + target_id="role-req-123", + target_name="Admin Role Request", + action="approved", + reason="Verified team lead authority", + payload={"role_group_id": "role-123"}, + metadata={}, + ) + assert envelope.event_type == "role_request_approve" + assert envelope.target_type == "role_request" + + def test_tag_management_event(self) -> None: + """Test envelope for tag management events.""" + envelope = AuditEventEnvelope( + id=uuid4(), + event_type="tag_create", + timestamp=datetime.now(timezone.utc), + actor_id="admin-789", + actor_email="admin@example.com", + target_type="tag", + target_id="tag-123", + target_name="Production", + action="created", + reason="New tag for production resources", + payload={"tag_name": "Production"}, + metadata={}, + ) + assert envelope.event_type == "tag_create" + assert envelope.target_type == "tag"