Skip to content

Commit 9da745f

Browse files
authored
Merge pull request #12 from PostHog/tom/fix
Send analytics to posthog
2 parents e995243 + db2f91b commit 9da745f

File tree

12 files changed

+408
-74
lines changed

12 files changed

+408
-74
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,8 @@ settings:
787787
| <a name="input_logs_retention_in_days"></a> [logs\_retention\_in\_days](#input\_logs\_retention\_in\_days) | The number of days you want to retain log events in the log group for both Lambda functions and API Gateway. | `number` | `365` | no |
788788
| <a name="input_max_permissions_duration_time"></a> [max\_permissions\_duration\_time](#input\_max\_permissions\_duration\_time) | Maximum duration (in hours) for permissions granted by Elevator. Max number - 48 hours.<br/> Due to Slack's dropdown limit of 100 items, anything above 48 hours will cause issues when generating half-hour increments<br/> and Elevator will not display more then 48 hours in the dropdown. | `number` | `24` | no |
789789
| <a name="input_permission_duration_list_override"></a> [permission\_duration\_list\_override](#input\_permission\_duration\_list\_override) | An explicit list of duration values to appear in the drop-down menu users use to select how long to request permissions for.<br/> Each entry in the list should be formatted as "hh:mm", e.g. "01:30" for an hour and a half. Note that while the number of minutes<br/> must be between 0-59, the number of hours can be any number.<br/> If this variable is set, the max\_permission\_duration\_time is ignored. | `list(string)` | `[]` | no |
790+
| <a name="input_posthog_api_key"></a> [posthog\_api\_key](#input\_posthog\_api\_key) | PostHog API key for analytics. Leave empty to disable analytics tracking. | `string` | `""` | no |
791+
| <a name="input_posthog_host"></a> [posthog\_host](#input\_posthog\_host) | PostHog host URL for analytics. | `string` | `"https://us.i.posthog.com"` | no |
790792
| <a name="input_request_expiration_hours"></a> [request\_expiration\_hours](#input\_request\_expiration\_hours) | After how many hours should the request expire? If set to 0, the request will never expire. | `number` | `8` | no |
791793
| <a name="input_requester_lambda_name"></a> [requester\_lambda\_name](#input\_requester\_lambda\_name) | value for the requester lambda name | `string` | `"access-requester"` | no |
792794
| <a name="input_revoker_lambda_name"></a> [revoker\_lambda\_name](#input\_revoker\_lambda\_name) | value for the revoker lambda name | `string` | `"access-revoker"` | no |

attribute_syncer_lambda.tf

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -48,26 +48,32 @@ module "attribute_syncer" {
4848
module.sso_elevator_dependencies[0].lambda_layer_arn,
4949
]
5050

51-
environment_variables = {
52-
LOG_LEVEL = var.log_level
53-
54-
SLACK_BOT_TOKEN = var.slack_bot_token
55-
SLACK_CHANNEL_ID = var.slack_channel_id
56-
57-
SSO_INSTANCE_ARN = local.sso_instance_arn
58-
IDENTITY_STORE_ID = local.identity_store_id
59-
POWERTOOLS_LOGGER_LOG_EVENT = true
60-
61-
S3_BUCKET_FOR_AUDIT_ENTRY_NAME = local.s3_bucket_name
62-
S3_BUCKET_PREFIX_FOR_PARTITIONS = var.s3_bucket_partition_prefix
63-
64-
# Attribute sync specific configuration
65-
ATTRIBUTE_SYNC_ENABLED = "true"
66-
ATTRIBUTE_SYNC_MANAGED_GROUPS = jsonencode(var.attribute_sync_managed_groups)
67-
ATTRIBUTE_SYNC_RULES = jsonencode(var.attribute_sync_rules)
68-
ATTRIBUTE_SYNC_MANUAL_ASSIGNMENT_POLICY = var.attribute_sync_manual_assignment_policy
69-
ATTRIBUTE_SYNC_SCHEDULE = var.attribute_sync_schedule
70-
}
51+
environment_variables = merge(
52+
{
53+
LOG_LEVEL = var.log_level
54+
55+
SLACK_BOT_TOKEN = var.slack_bot_token
56+
SLACK_CHANNEL_ID = var.slack_channel_id
57+
58+
SSO_INSTANCE_ARN = local.sso_instance_arn
59+
IDENTITY_STORE_ID = local.identity_store_id
60+
POWERTOOLS_LOGGER_LOG_EVENT = true
61+
62+
S3_BUCKET_FOR_AUDIT_ENTRY_NAME = local.s3_bucket_name
63+
S3_BUCKET_PREFIX_FOR_PARTITIONS = var.s3_bucket_partition_prefix
64+
65+
# Attribute sync specific configuration
66+
ATTRIBUTE_SYNC_ENABLED = "true"
67+
ATTRIBUTE_SYNC_MANAGED_GROUPS = jsonencode(var.attribute_sync_managed_groups)
68+
ATTRIBUTE_SYNC_RULES = jsonencode(var.attribute_sync_rules)
69+
ATTRIBUTE_SYNC_MANUAL_ASSIGNMENT_POLICY = var.attribute_sync_manual_assignment_policy
70+
ATTRIBUTE_SYNC_SCHEDULE = var.attribute_sync_schedule
71+
},
72+
var.posthog_api_key != "" ? {
73+
POSTHOG_API_KEY = var.posthog_api_key
74+
POSTHOG_HOST = var.posthog_host
75+
} : {}
76+
)
7177

7278
allowed_triggers = {
7379
attribute_sync_schedule = {

perm_revoker_lambda.tf

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -43,35 +43,41 @@ module "access_revoker" {
4343
module.sso_elevator_dependencies[0].lambda_layer_arn,
4444
]
4545

46-
environment_variables = {
47-
LOG_LEVEL = var.log_level
46+
environment_variables = merge(
47+
{
48+
LOG_LEVEL = var.log_level
4849

49-
SLACK_SIGNING_SECRET = var.slack_signing_secret
50-
SLACK_BOT_TOKEN = var.slack_bot_token
51-
SLACK_CHANNEL_ID = var.slack_channel_id
52-
SCHEDULE_GROUP_NAME = var.schedule_group_name
50+
SLACK_SIGNING_SECRET = var.slack_signing_secret
51+
SLACK_BOT_TOKEN = var.slack_bot_token
52+
SLACK_CHANNEL_ID = var.slack_channel_id
53+
SCHEDULE_GROUP_NAME = var.schedule_group_name
5354

54-
SSO_INSTANCE_ARN = local.sso_instance_arn
55-
POWERTOOLS_LOGGER_LOG_EVENT = true
55+
SSO_INSTANCE_ARN = local.sso_instance_arn
56+
POWERTOOLS_LOGGER_LOG_EVENT = true
5657

57-
POST_UPDATE_TO_SLACK = var.revoker_post_update_to_slack
58-
SCHEDULE_POLICY_ARN = aws_iam_role.eventbridge_role.arn
59-
REVOKER_FUNCTION_ARN = local.revoker_lambda_arn
60-
REVOKER_FUNCTION_NAME = var.revoker_lambda_name
61-
S3_BUCKET_FOR_AUDIT_ENTRY_NAME = local.s3_bucket_name
62-
S3_BUCKET_PREFIX_FOR_PARTITIONS = var.s3_bucket_partition_prefix
63-
SSO_ELEVATOR_SCHEDULED_REVOCATION_RULE_NAME = aws_cloudwatch_event_rule.sso_elevator_scheduled_revocation.name
64-
REQUEST_EXPIRATION_HOURS = var.request_expiration_hours
65-
MAX_PERMISSIONS_DURATION_TIME = var.max_permissions_duration_time
66-
PERMISSION_DURATION_LIST_OVERRIDE = jsonencode(var.permission_duration_list_override)
67-
CONFIG_BUCKET_NAME = local.config_bucket_name
68-
CONFIG_S3_KEY = "config/approval-config.json"
58+
POST_UPDATE_TO_SLACK = var.revoker_post_update_to_slack
59+
SCHEDULE_POLICY_ARN = aws_iam_role.eventbridge_role.arn
60+
REVOKER_FUNCTION_ARN = local.revoker_lambda_arn
61+
REVOKER_FUNCTION_NAME = var.revoker_lambda_name
62+
S3_BUCKET_FOR_AUDIT_ENTRY_NAME = local.s3_bucket_name
63+
S3_BUCKET_PREFIX_FOR_PARTITIONS = var.s3_bucket_partition_prefix
64+
SSO_ELEVATOR_SCHEDULED_REVOCATION_RULE_NAME = aws_cloudwatch_event_rule.sso_elevator_scheduled_revocation.name
65+
REQUEST_EXPIRATION_HOURS = var.request_expiration_hours
66+
MAX_PERMISSIONS_DURATION_TIME = var.max_permissions_duration_time
67+
PERMISSION_DURATION_LIST_OVERRIDE = jsonencode(var.permission_duration_list_override)
68+
CONFIG_BUCKET_NAME = local.config_bucket_name
69+
CONFIG_S3_KEY = "config/approval-config.json"
6970

70-
APPROVER_RENOTIFICATION_INITIAL_WAIT_TIME = var.approver_renotification_initial_wait_time
71-
APPROVER_RENOTIFICATION_BACKOFF_MULTIPLIER = var.approver_renotification_backoff_multiplier
72-
SECONDARY_FALLBACK_EMAIL_DOMAINS = jsonencode(var.secondary_fallback_email_domains)
73-
SEND_DM_IF_USER_NOT_IN_CHANNEL = var.send_dm_if_user_not_in_channel
74-
}
71+
APPROVER_RENOTIFICATION_INITIAL_WAIT_TIME = var.approver_renotification_initial_wait_time
72+
APPROVER_RENOTIFICATION_BACKOFF_MULTIPLIER = var.approver_renotification_backoff_multiplier
73+
SECONDARY_FALLBACK_EMAIL_DOMAINS = jsonencode(var.secondary_fallback_email_domains)
74+
SEND_DM_IF_USER_NOT_IN_CHANNEL = var.send_dm_if_user_not_in_channel
75+
},
76+
var.posthog_api_key != "" ? {
77+
POSTHOG_API_KEY = var.posthog_api_key
78+
POSTHOG_HOST = var.posthog_host
79+
} : {}
80+
)
7581

7682
allowed_triggers = {
7783
cron = {

slack_handler_lambda.tf

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -43,36 +43,42 @@ module "access_requester_slack_handler" {
4343
module.sso_elevator_dependencies[0].lambda_layer_arn,
4444
]
4545

46-
environment_variables = {
47-
LOG_LEVEL = var.log_level
46+
environment_variables = merge(
47+
{
48+
LOG_LEVEL = var.log_level
4849

49-
SLACK_SIGNING_SECRET = var.slack_signing_secret
50-
SLACK_BOT_TOKEN = var.slack_bot_token
51-
SLACK_CHANNEL_ID = var.slack_channel_id
52-
SCHEDULE_GROUP_NAME = var.schedule_group_name
50+
SLACK_SIGNING_SECRET = var.slack_signing_secret
51+
SLACK_BOT_TOKEN = var.slack_bot_token
52+
SLACK_CHANNEL_ID = var.slack_channel_id
53+
SCHEDULE_GROUP_NAME = var.schedule_group_name
5354

54-
POST_UPDATE_TO_SLACK = var.revoker_post_update_to_slack
55+
POST_UPDATE_TO_SLACK = var.revoker_post_update_to_slack
5556

56-
SSO_INSTANCE_ARN = local.sso_instance_arn
57-
POWERTOOLS_LOGGER_LOG_EVENT = true
58-
SCHEDULE_POLICY_ARN = aws_iam_role.eventbridge_role.arn
59-
REVOKER_FUNCTION_ARN = local.revoker_lambda_arn
60-
REVOKER_FUNCTION_NAME = var.revoker_lambda_name
61-
S3_BUCKET_FOR_AUDIT_ENTRY_NAME = local.s3_bucket_name
62-
S3_BUCKET_PREFIX_FOR_PARTITIONS = var.s3_bucket_partition_prefix
63-
SSO_ELEVATOR_SCHEDULED_REVOCATION_RULE_NAME = aws_cloudwatch_event_rule.sso_elevator_scheduled_revocation.name
64-
REQUEST_EXPIRATION_HOURS = var.request_expiration_hours
65-
APPROVER_RENOTIFICATION_INITIAL_WAIT_TIME = var.approver_renotification_initial_wait_time
66-
APPROVER_RENOTIFICATION_BACKOFF_MULTIPLIER = var.approver_renotification_backoff_multiplier
67-
MAX_PERMISSIONS_DURATION_TIME = var.max_permissions_duration_time
68-
PERMISSION_DURATION_LIST_OVERRIDE = jsonencode(var.permission_duration_list_override)
69-
SECONDARY_FALLBACK_EMAIL_DOMAINS = jsonencode(var.secondary_fallback_email_domains)
70-
SEND_DM_IF_USER_NOT_IN_CHANNEL = var.send_dm_if_user_not_in_channel
71-
ALLOW_ANYONE_TO_END_SESSION_EARLY = var.allow_anyone_to_end_session_early
72-
CONFIG_BUCKET_NAME = local.config_bucket_name
73-
CONFIG_S3_KEY = "config/approval-config.json"
74-
CACHE_ENABLED = var.cache_enabled
75-
}
57+
SSO_INSTANCE_ARN = local.sso_instance_arn
58+
POWERTOOLS_LOGGER_LOG_EVENT = true
59+
SCHEDULE_POLICY_ARN = aws_iam_role.eventbridge_role.arn
60+
REVOKER_FUNCTION_ARN = local.revoker_lambda_arn
61+
REVOKER_FUNCTION_NAME = var.revoker_lambda_name
62+
S3_BUCKET_FOR_AUDIT_ENTRY_NAME = local.s3_bucket_name
63+
S3_BUCKET_PREFIX_FOR_PARTITIONS = var.s3_bucket_partition_prefix
64+
SSO_ELEVATOR_SCHEDULED_REVOCATION_RULE_NAME = aws_cloudwatch_event_rule.sso_elevator_scheduled_revocation.name
65+
REQUEST_EXPIRATION_HOURS = var.request_expiration_hours
66+
APPROVER_RENOTIFICATION_INITIAL_WAIT_TIME = var.approver_renotification_initial_wait_time
67+
APPROVER_RENOTIFICATION_BACKOFF_MULTIPLIER = var.approver_renotification_backoff_multiplier
68+
MAX_PERMISSIONS_DURATION_TIME = var.max_permissions_duration_time
69+
PERMISSION_DURATION_LIST_OVERRIDE = jsonencode(var.permission_duration_list_override)
70+
SECONDARY_FALLBACK_EMAIL_DOMAINS = jsonencode(var.secondary_fallback_email_domains)
71+
SEND_DM_IF_USER_NOT_IN_CHANNEL = var.send_dm_if_user_not_in_channel
72+
ALLOW_ANYONE_TO_END_SESSION_EARLY = var.allow_anyone_to_end_session_early
73+
CONFIG_BUCKET_NAME = local.config_bucket_name
74+
CONFIG_S3_KEY = "config/approval-config.json"
75+
CACHE_ENABLED = var.cache_enabled
76+
},
77+
var.posthog_api_key != "" ? {
78+
POSTHOG_API_KEY = var.posthog_api_key
79+
POSTHOG_HOST = var.posthog_host
80+
} : {}
81+
)
7682

7783
# Only create API Gateway trigger on base function when provisioned concurrency is disabled.
7884
# When enabled, the alias module creates the trigger instead.
@@ -141,7 +147,12 @@ data "aws_iam_policy_document" "slack_handler" {
141147
"lambda:InvokeFunction",
142148
"lambda:GetFunction"
143149
]
144-
resources = [local.requester_lambda_arn]
150+
# Include both qualified (:live alias) and unqualified ARN for lazy listener self-invocation
151+
# when provisioned concurrency is enabled
152+
resources = distinct([
153+
local.requester_lambda_arn,
154+
"arn:aws:lambda:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:function:${var.requester_lambda_name}"
155+
])
145156
}
146157
statement {
147158
effect = "Allow"
@@ -218,6 +229,7 @@ data "aws_iam_policy_document" "slack_handler" {
218229
"identitystore:ListGroups",
219230
"identitystore:DescribeGroup",
220231
"identitystore:ListGroupMemberships",
232+
"identitystore:ListGroupMembershipsForMember",
221233
"identitystore:CreateGroupMembership",
222234
]
223235
resources = ["*"]

src/analytics.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""PostHog analytics integration for SSO Elevator.
2+
3+
This module provides optional analytics tracking when a PostHog API key is configured.
4+
All events include a global "application" property for filtering.
5+
6+
Usage:
7+
import analytics
8+
9+
analytics.capture(
10+
event="aws_access_requested",
11+
distinct_id=requester.email,
12+
properties={"account_id": "123456789012", ...}
13+
)
14+
15+
# Call shutdown before Lambda freeze to flush events
16+
analytics.shutdown()
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import os
22+
from functools import lru_cache
23+
from typing import TYPE_CHECKING
24+
25+
if TYPE_CHECKING:
26+
import posthog as posthog_module
27+
28+
APPLICATION = "aws-sso-elevator"
29+
30+
31+
@lru_cache(maxsize=1)
32+
def get_posthog_client() -> posthog_module | None:
33+
"""Get configured PostHog client, or None if not configured.
34+
35+
Returns cached client instance. The client is configured on first call
36+
if POSTHOG_API_KEY environment variable is set.
37+
"""
38+
api_key = os.environ.get("POSTHOG_API_KEY")
39+
if not api_key:
40+
return None
41+
42+
import posthog
43+
44+
posthog.api_key = api_key
45+
posthog.host = os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com")
46+
return posthog
47+
48+
49+
def capture(event: str, distinct_id: str, properties: dict | None = None) -> None:
50+
"""Capture an analytics event if PostHog is configured.
51+
52+
Adds the global "application" property to all events for filtering.
53+
54+
Args:
55+
event: The event name (e.g., "aws_access_requested").
56+
distinct_id: Unique identifier for the user (typically email).
57+
properties: Optional dict of event properties.
58+
"""
59+
client = get_posthog_client()
60+
if client:
61+
all_properties = {"application": APPLICATION, **(properties or {})}
62+
client.capture(distinct_id, event, all_properties)
63+
64+
65+
def shutdown() -> None:
66+
"""Flush pending events before Lambda freeze.
67+
68+
Should be called at the end of Lambda handler to ensure
69+
all events are sent before the container is frozen.
70+
"""
71+
client = get_posthog_client()
72+
if client:
73+
client.flush()

src/errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from slack_bolt import BoltContext
55
from slack_sdk import WebClient
66

7+
import analytics
78
import config
89

910

@@ -27,6 +28,15 @@ def error_handler(client: WebClient, e: Exception, logger: Logger, context: Bolt
2728
logger.exception("An error occurred:", exc_info=e)
2829
user_id = context.get("user_id", "UNKNOWN_USER")
2930

31+
analytics.capture(
32+
event="aws_sso_elevator_error",
33+
distinct_id=user_id,
34+
properties={
35+
"error_type": type(e).__name__,
36+
"error_message": str(e),
37+
},
38+
)
39+
3040
if isinstance(e, SSOUserNotFound):
3141
text = (
3242
f"<@{user_id}> Your request for AWS permissions failed because SSO Elevator could not find your user in AWS SSO. "

src/group.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from slack_sdk.web.slack_response import SlackResponse
1111

1212
import access_control
13+
import analytics
1314
import config
1415
import entities
1516
import schedule
@@ -48,6 +49,17 @@ def handle_request_for_group_access_submittion( # noqa: PLR0915
4849
group_id=request.group_id,
4950
)
5051

52+
analytics.capture(
53+
event="aws_group_access_requested",
54+
distinct_id=requester.email,
55+
properties={
56+
"group_id": request.group_id,
57+
"group_name": group.name,
58+
"requester_email": requester.email,
59+
"decision_reason": decision.reason.value,
60+
},
61+
)
62+
5163
show_buttons = bool(decision.approvers)
5264
slack_response = client.chat_postMessage(
5365
blocks=slack_helpers.build_approval_request_message_blocks(
@@ -142,6 +154,19 @@ def handle_request_for_group_access_submittion( # noqa: PLR0915
142154
)
143155

144156
if result.granted:
157+
analytics.capture(
158+
event="aws_group_access_approved",
159+
distinct_id=requester.email,
160+
properties={
161+
"group_id": request.group_id,
162+
"group_name": group.name,
163+
"approver_email": requester.email,
164+
"requester_email": requester.email,
165+
"duration_hours": request.permission_duration.total_seconds() / 3600,
166+
"self_approved": True,
167+
},
168+
)
169+
145170
client.chat_postMessage(
146171
channel=cfg.slack_channel_id,
147172
text=f"Permissions granted to <@{requester.id}>",
@@ -271,6 +296,21 @@ def handle_group_button_click(body: dict, client: WebClient, context: BoltContex
271296
identity_store_id=identity_store_id,
272297
thread_ts=payload.thread_ts,
273298
)
299+
300+
if result.granted:
301+
analytics.capture(
302+
event="aws_group_access_approved",
303+
distinct_id=requester.email,
304+
properties={
305+
"group_id": payload.request.group_id,
306+
"group_name": group.name,
307+
"approver_email": approver.email,
308+
"requester_email": requester.email,
309+
"duration_hours": payload.request.permission_duration.total_seconds() / 3600,
310+
"self_approved": approver.email == requester.email,
311+
},
312+
)
313+
274314
cache_for_dublicate_requests.clear()
275315
if cfg.send_dm_if_user_not_in_channel and not is_user_in_channel:
276316
logger.info(f"User {requester.id} is not in the channel. Sending DM with message: {dm_text}")

0 commit comments

Comments
 (0)