Skip to content

Commit e995243

Browse files
authored
Merge pull request #11 from PostHog/tom/grousp
Add option to limit requests based on group membership
2 parents a7c081d + 176700b commit e995243

File tree

14 files changed

+812
-81
lines changed

14 files changed

+812
-81
lines changed

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,7 @@ settings:
716716
| <a name="module_audit_bucket"></a> [audit\_bucket](#module\_audit\_bucket) | fivexl/account-baseline/aws//modules/s3_baseline | 2.0.0 |
717717
| <a name="module_config_bucket"></a> [config\_bucket](#module\_config\_bucket) | fivexl/account-baseline/aws//modules/s3_baseline | 2.0.0 |
718718
| <a name="module_http_api"></a> [http\_api](#module\_http\_api) | terraform-aws-modules/apigateway-v2/aws | 5.0.0 |
719+
| <a name="module_slack_handler_alias"></a> [slack\_handler\_alias](#module\_slack\_handler\_alias) | terraform-aws-modules/lambda/aws//modules/alias | 8.1.2 |
719720
| <a name="module_sso_elevator_dependencies"></a> [sso\_elevator\_dependencies](#module\_sso\_elevator\_dependencies) | terraform-aws-modules/lambda/aws | 8.1.2 |
720721

721722
## Resources
@@ -731,7 +732,7 @@ settings:
731732
| [aws_iam_role.eventbridge_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
732733
| [aws_iam_role_policy.eventbridge_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
733734
| [aws_lambda_permission.eventbridge](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource |
734-
| [aws_lambda_permission.url](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission) | resource |
735+
| [aws_lambda_provisioned_concurrency_config.slack_handler](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_provisioned_concurrency_config) | resource |
735736
| [aws_s3_object.approval_config](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_object) | resource |
736737
| [aws_scheduler_schedule_group.one_time_schedule_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/scheduler_schedule_group) | resource |
737738
| [aws_sns_topic.dlq](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic) | resource |
@@ -749,10 +750,10 @@ settings:
749750

750751
| Name | Description | Type | Default | Required |
751752
|------|-------------|------|---------|:--------:|
753+
| <a name="input_allow_anyone_to_end_session_early"></a> [allow\_anyone\_to\_end\_session\_early](#input\_allow\_anyone\_to\_end\_session\_early) | Controls who can click the "End session early" button to revoke access before the scheduled expiration.<br/>If false (default), only the requester and approvers listed in the matching statement can end the session.<br/>If true, anyone in the Slack channel can end any session early. | `bool` | `false` | no |
752754
| <a name="input_api_gateway_name"></a> [api\_gateway\_name](#input\_api\_gateway\_name) | The name of the API Gateway for SSO Elevator's access-requester Lambda | `string` | `"sso-elevator-access-requster"` | no |
753755
| <a name="input_api_gateway_throttling_burst_limit"></a> [api\_gateway\_throttling\_burst\_limit](#input\_api\_gateway\_throttling\_burst\_limit) | The maximum number of requests that API Gateway allows in a burst. | `number` | `5` | no |
754756
| <a name="input_api_gateway_throttling_rate_limit"></a> [api\_gateway\_throttling\_rate\_limit](#input\_api\_gateway\_throttling\_rate\_limit) | The maximum number of requests that API Gateway allows per second. | `number` | `1` | no |
755-
| <a name="input_allow_anyone_to_end_session_early"></a> [allow\_anyone\_to\_end\_session\_early](#input\_allow\_anyone\_to\_end\_session\_early) | Controls who can click the "End session early" button. If false (default), only the requester and approvers can end the session. If true, anyone in the channel can end any session. | `bool` | `false` | no |
756757
| <a name="input_approver_renotification_backoff_multiplier"></a> [approver\_renotification\_backoff\_multiplier](#input\_approver\_renotification\_backoff\_multiplier) | The multiplier applied to the wait time for each subsequent notification sent to the approver. Default is 2, which means the wait time will double for each attempt. | `number` | `2` | no |
757758
| <a name="input_approver_renotification_initial_wait_time"></a> [approver\_renotification\_initial\_wait\_time](#input\_approver\_renotification\_initial\_wait\_time) | The initial wait time before the first re-notification to the approver is sent. This is measured in minutes. If set to 0, no re-notifications will be sent. | `number` | `15` | no |
758759
| <a name="input_attribute_sync_enabled"></a> [attribute\_sync\_enabled](#input\_attribute\_sync\_enabled) | Enable attribute-based group sync feature. When enabled, users will be automatically added to groups based on their Identity Store attributes. | `bool` | `false` | no |
@@ -786,7 +787,6 @@ settings:
786787
| <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 |
787788
| <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 |
788789
| <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 |
789-
| <a name="input_provisioned_concurrent_executions"></a> [provisioned\_concurrent\_executions](#input\_provisioned\_concurrent\_executions) | Provisioned concurrent executions for the Slack handler Lambda. Set to a positive number to reduce cold starts. | `number` | `-1` | no |
790790
| <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 |
791791
| <a name="input_requester_lambda_name"></a> [requester\_lambda\_name](#input\_requester\_lambda\_name) | value for the requester lambda name | `string` | `"access-requester"` | no |
792792
| <a name="input_revoker_lambda_name"></a> [revoker\_lambda\_name](#input\_revoker\_lambda\_name) | value for the revoker lambda name | `string` | `"access-revoker"` | no |
@@ -806,6 +806,7 @@ settings:
806806
| <a name="input_send_dm_if_user_not_in_channel"></a> [send\_dm\_if\_user\_not\_in\_channel](#input\_send\_dm\_if\_user\_not\_in\_channel) | If the user is not in the SSO Elevator channel, Elevator will send them a direct message with the request status <br/>(waiting for approval, declined, approved, etc.) and the result of the request.<br/>Using this feature requires the following Slack app permissions: "channels:read", "groups:read", and "im:write". <br/>Please ensure these permissions are enabled in the Slack app configuration. | `bool` | `true` | no |
807807
| <a name="input_slack_bot_token"></a> [slack\_bot\_token](#input\_slack\_bot\_token) | value for the Slack bot token | `string` | n/a | yes |
808808
| <a name="input_slack_channel_id"></a> [slack\_channel\_id](#input\_slack\_channel\_id) | value for the Slack channel ID | `string` | n/a | yes |
809+
| <a name="input_slack_handler_provisioned_concurrent_executions"></a> [slack\_handler\_provisioned\_concurrent\_executions](#input\_slack\_handler\_provisioned\_concurrent\_executions) | Provisioned concurrent executions for the Slack handler Lambda. Set to a positive number to reduce cold starts. | `number` | `-1` | no |
809810
| <a name="input_slack_signing_secret"></a> [slack\_signing\_secret](#input\_slack\_signing\_secret) | value for the Slack signing secret | `string` | n/a | yes |
810811
| <a name="input_sso_instance_arn"></a> [sso\_instance\_arn](#input\_sso\_instance\_arn) | value for the SSO instance ARN | `string` | `""` | no |
811812
| <a name="input_tags"></a> [tags](#input\_tags) | A map of tags to assign to resources. | `map(string)` | `{}` | no |

src/access_control.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import schedule
1111
import sso
1212
from entities import BaseModel
13-
from statement import GroupStatement, Statement, get_affected_group_statements, get_affected_statements
13+
from statement import GroupStatement, Statement, get_affected_group_statements, get_affected_statements, get_eligible_statements_for_user
1414

1515
logger = config.get_logger("access_control")
1616
cfg = config.get_config()
@@ -55,13 +55,31 @@ def determine_affected_statements(
5555
raise TypeError("Statements contain mixed or unsupported types.")
5656

5757

58-
def make_decision_on_access_request( # noqa: PLR0911
58+
def make_decision_on_access_request( # noqa: PLR0911, PLR0913
5959
statements: FrozenSet[Statement] | FrozenSet[GroupStatement],
6060
requester_email: str,
6161
permission_set_name: str | None = None,
6262
account_id: str | None = None,
6363
group_id: str | None = None,
64+
user_group_ids: set[str] | None = None,
6465
) -> AccessRequestDecision:
66+
"""Make a decision on an access request.
67+
68+
Args:
69+
statements: The statements to evaluate.
70+
requester_email: The email of the user requesting access.
71+
permission_set_name: The name of the permission set being requested.
72+
account_id: The AWS account ID being requested.
73+
group_id: The group ID for group-based access requests.
74+
user_group_ids: The set of SSO group IDs the user belongs to.
75+
If None, group-based filtering is skipped (backwards compatible).
76+
If an empty set, only statements without required_group_membership will match.
77+
"""
78+
# Filter statements by user's group membership eligibility if user_group_ids provided
79+
# This is only applicable to Statement (not GroupStatement)
80+
if user_group_ids is not None and isinstance(statements, frozenset) and all(isinstance(s, Statement) for s in statements):
81+
statements = get_eligible_statements_for_user(statements, user_group_ids) # type: ignore # noqa: PGH003
82+
6583
affected_statements = determine_affected_statements(statements, account_id, permission_set_name, group_id)
6684

6785
decision_based_on_statements: set[Statement] | set[GroupStatement] = set()

src/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def to_set_if_list_or_str(v: list | str) -> frozenset[str]:
8282
"resource_type": _dict.get("ResourceType"),
8383
"approval_is_not_required": _dict.get("ApprovalIsNotRequired"),
8484
"allow_self_approval": _dict.get("AllowSelfApproval"),
85+
"required_group_membership": to_set_if_list_or_str(_dict.get("RequiredGroupMembership", set())),
8586
}
8687
)
8788

src/group.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929

3030

3131
@handle_errors
32-
def handle_request_for_group_access_submittion(
32+
def handle_request_for_group_access_submittion( # noqa: PLR0915
3333
body: dict,
3434
ack: Ack, # noqa: ARG001
3535
client: WebClient,

src/main.py

Lines changed: 92 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,49 @@ def load_select_options_for_account_access_request(client: WebClient, body: dict
145145
logger.info("Loading select options for view (accounts only)")
146146
logger.debug("Request body", extra={"body": body})
147147

148-
accounts = organizations.get_accounts_from_config_with_cache(org_client=org_client, s3_client=s3_client, cfg=cfg)
149-
150148
user_id = body.get("user", {}).get("id")
151149
callback_id = slack_helpers.RequestForAccessView.CALLBACK_ID
152150
view_key = f"{user_id}:{callback_id}"
153151

152+
# Get user's SSO info and group memberships for filtering
153+
sso_instance = sso.describe_sso_instance(sso_client, cfg.sso_instance_arn)
154+
user_email = slack_helpers.get_user(client, id=user_id).email
155+
user_principal_id, _ = sso.get_user_principal_id_by_email(
156+
identity_store_client=identity_store_client,
157+
identity_store_id=sso_instance.identity_store_id,
158+
email=user_email,
159+
cfg=cfg,
160+
)
161+
user_group_ids = sso.get_user_group_ids(
162+
identity_store_client=identity_store_client,
163+
identity_store_id=sso_instance.identity_store_id,
164+
user_principal_id=user_principal_id,
165+
)
166+
167+
# Cache user_group_ids for use in handle_account_selection
168+
user_view_map[f"{view_key}:group_ids"] = user_group_ids
169+
170+
# Filter accounts based on user's eligible statements
171+
eligible_account_ids = statement.get_accounts_for_user(cfg.statements, user_group_ids)
172+
154173
view_id = user_view_map.get(view_key)
174+
175+
# If no eligible accounts, show empty view
176+
if not eligible_account_ids:
177+
logger.info("User has no eligible accounts", extra={"user_id": user_id})
178+
view = slack_helpers.RequestForAccessView.build_no_eligible_accounts_view()
179+
if view_id:
180+
return client.views_update(view_id=view_id, view=view)
181+
trigger_id = body["trigger_id"]
182+
return client.views_open(trigger_id=trigger_id, view=view)
183+
184+
# Get all accounts and filter to eligible ones
185+
all_accounts = organizations.get_accounts_from_config_with_cache(org_client=org_client, s3_client=s3_client, cfg=cfg)
186+
if "*" in eligible_account_ids:
187+
accounts = all_accounts
188+
else:
189+
accounts = [a for a in all_accounts if a.id in eligible_account_ids]
190+
155191
if not view_id:
156192
logger.warning(
157193
f"View ID not found for key: {view_key}. "
@@ -350,15 +386,35 @@ def handle_request_for_access_submittion( # noqa: PLR0915, PLR0912
350386
request = slack_helpers.RequestForAccessView.parse(body)
351387
logger.info("View submitted", extra={"view": request})
352388
requester = slack_helpers.get_user(client, id=request.requester_slack_id)
389+
390+
# Get user's group memberships for eligibility check (defense in depth)
391+
sso_instance = sso.describe_sso_instance(sso_client, cfg.sso_instance_arn)
392+
user_principal_id, _ = sso.get_user_principal_id_by_email(
393+
identity_store_client=identity_store_client,
394+
identity_store_id=sso_instance.identity_store_id,
395+
email=requester.email,
396+
cfg=cfg,
397+
)
398+
user_group_ids = sso.get_user_group_ids(
399+
identity_store_client=identity_store_client,
400+
identity_store_id=sso_instance.identity_store_id,
401+
user_principal_id=user_principal_id,
402+
)
403+
353404
decision = access_control.make_decision_on_access_request(
354405
cfg.statements,
355406
account_id=request.account_id,
356407
permission_set_name=request.permission_set_name,
357408
requester_email=requester.email,
409+
user_group_ids=user_group_ids,
358410
)
359411
logger.info("Decision on request was made", extra={"decision": decision.dict()})
360412

361-
account = organizations.describe_account(org_client, request.account_id)
413+
try:
414+
account = organizations.describe_account(org_client, request.account_id)
415+
except Exception:
416+
logger.warning("Failed to describe account, using account ID as fallback", extra={"account_id": request.account_id})
417+
account = entities.aws.Account(id=request.account_id, name=request.account_id)
362418

363419
show_buttons = bool(decision.approvers)
364420
slack_response = client.chat_postMessage(
@@ -535,14 +591,27 @@ def handle_account_selection(ack: Ack, body: dict, client: WebClient) -> SlackRe
535591
)
536592
logger.info(f"Selected account: {account_id}")
537593

538-
valid_ps_names = statement.get_permission_sets_for_account(cfg.statements, account_id)
539-
logger.info(f"Valid permission sets for account: {valid_ps_names}")
594+
# Get cached user_group_ids
595+
user_id = body.get("user", {}).get("id")
596+
callback_id = slack_helpers.RequestForAccessView.CALLBACK_ID
597+
view_key = f"{user_id}:{callback_id}"
598+
group_ids_key = f"{view_key}:group_ids"
599+
user_group_ids = user_view_map.get(group_ids_key)
600+
if user_group_ids is None:
601+
logger.warning(
602+
f"User group IDs not found in cache for key: {group_ids_key}. "
603+
"This may happen if Lambda container was recycled between form load and account selection. "
604+
"Defaulting to empty set, which will restrict visibility to statements without required_group_membership."
605+
)
606+
user_group_ids = set()
607+
608+
# Filter permission sets based on user's eligible statements
609+
valid_ps_names = statement.get_permission_sets_for_account_and_user(cfg.statements, account_id, user_group_ids)
610+
logger.info(f"Valid permission sets for account and user: {valid_ps_names}")
540611

541612
if not valid_ps_names:
542613
view_id = body["view"]["id"]
543-
updated_view = slack_helpers.RequestForAccessView.build_no_permission_sets_view(
544-
view_blocks=body["view"]["blocks"]
545-
)
614+
updated_view = slack_helpers.RequestForAccessView.build_no_permission_sets_view(view_blocks=body["view"]["blocks"])
546615
return client.views_update(view_id=view_id, view=updated_view)
547616

548617
if "*" in valid_ps_names:
@@ -554,9 +623,7 @@ def handle_account_selection(ack: Ack, body: dict, client: WebClient) -> SlackRe
554623
# Handle case where filtered list is empty (configured names don't exist in SSO)
555624
if not permission_sets:
556625
view_id = body["view"]["id"]
557-
updated_view = slack_helpers.RequestForAccessView.build_no_permission_sets_view(
558-
view_blocks=body["view"]["blocks"]
559-
)
626+
updated_view = slack_helpers.RequestForAccessView.build_no_permission_sets_view(view_blocks=body["view"]["blocks"])
560627
return client.views_update(view_id=view_id, view=updated_view)
561628

562629
view_id = body["view"]["id"]
@@ -654,11 +721,13 @@ def handle_early_revoke_button_click(body: dict, client: WebClient, context: Bol
654721
except Exception:
655722
account_name = button_payload.account_id
656723

657-
private_metadata = json.dumps({
658-
"button_payload": button_payload.model_dump(mode="json"),
659-
"channel_id": channel_id,
660-
"thread_ts": thread_ts,
661-
})
724+
private_metadata = json.dumps(
725+
{
726+
"button_payload": button_payload.model_dump(mode="json"),
727+
"channel_id": channel_id,
728+
"thread_ts": thread_ts,
729+
}
730+
)
662731

663732
modal = slack_helpers.EarlyRevokeModal.build(
664733
account_name=account_name,
@@ -668,11 +737,13 @@ def handle_early_revoke_button_click(body: dict, client: WebClient, context: Bol
668737
)
669738
elif button_payload.group_id and button_payload.group_name:
670739
# Group access
671-
private_metadata = json.dumps({
672-
"button_payload": button_payload.model_dump(mode="json"),
673-
"channel_id": channel_id,
674-
"thread_ts": thread_ts,
675-
})
740+
private_metadata = json.dumps(
741+
{
742+
"button_payload": button_payload.model_dump(mode="json"),
743+
"channel_id": channel_id,
744+
"thread_ts": thread_ts,
745+
}
746+
)
676747

677748
modal = slack_helpers.EarlyRevokeModal.build(
678749
group_name=button_payload.group_name,

0 commit comments

Comments
 (0)