Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 6 additions & 6 deletions api/operations/__init__.py
Original file line number Diff line number Diff line change
@@ -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__ = [
Expand Down
56 changes: 48 additions & 8 deletions api/operations/approve_access_request.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
57 changes: 49 additions & 8 deletions api/operations/approve_role_request.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 1 addition & 5 deletions api/operations/constraints/check_for_reason.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
6 changes: 1 addition & 5 deletions api/operations/constraints/check_for_self_add.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
65 changes: 48 additions & 17 deletions api/operations/create_access_request.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading