Skip to content

Commit 3deaf17

Browse files
authored
Merge pull request #27 from PostHog/tom/approver-groups
Allow specifying groups of approvers
2 parents 0316657 + e999372 commit 3deaf17

File tree

16 files changed

+1150
-71
lines changed

16 files changed

+1150
-71
lines changed

docs/CONFIGURATION.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ The fields in the configuration dictionary are:
1414
- **Resource**: This field defines the specific resource(s) being requested. It accepts either a single string or a list of strings. Setting this field to "*" allows the rule to match all resources associated with the specified `ResourceType`.
1515
- **PermissionSet**: Here, you indicate the permission set(s) being requested. This can be either a single string or a list of strings. You can specify permission sets by **name** (e.g., `"AdministratorAccess"`) or by **ARN** (e.g., `"arn:aws:sso:::permissionSet/ssoins-1234567890abcdef/ps-1234567890abcdef"`). Using ARNs is recommended for Terraform users as it reduces API calls and allows direct reference to `aws_ssoadmin_permission_set.*.arn`. If set to "*", the rule matches all permission sets available for the defined `Resource` and `ResourceType`.
1616
- **Approvers**: This field lists the potential approvers for the request. It accepts either a single string or a list of strings representing different approvers.
17-
- **AllowSelfApproval**: This field can be a boolean, indicating whether the requester, if present in the `Approvers` list, is permitted to approve their own request. It defaults to `None`.
17+
- **ApproverGroups**: This field lists Slack usergroup IDs whose members can approve the request. It accepts either a single string or a list of strings. Members of the specified Slack usergroups are resolved at request time and added to the list of approvers.
18+
- **AllowSelfApproval**: This field can be a boolean, indicating whether the requester, if present in the `Approvers` list or a member of an `ApproverGroups` group, is permitted to approve their own request. It defaults to `None`.
1819
- **ApprovalIsNotRequired**: This field can also be a boolean, signifying whether the approval can be granted automatically, bypassing the approvers entirely. The default value is `None`.
1920
- **RequiredGroupMembership**: This field restricts the rule to only users who are members of at least one of the specified SSO groups. Accepts a single group ID or a list of group IDs. If empty or omitted, the rule applies to all users.
2021

@@ -26,16 +27,16 @@ In the system, an explicit denial in any statement overrides any approvals. For
2627

2728
Requests will be approved automatically if either of the following conditions are met:
2829

29-
- AllowSelfApproval is set to true and the requester is in the Approvers list.
30+
- AllowSelfApproval is set to true and the requester is in the Approvers list or a member of an ApproverGroups group.
3031
- ApprovalIsNotRequired is set to true.
3132

3233
## Aggregation of Rules
3334

34-
The approval decision and final list of reviewers will be calculated dynamically based on the aggregate of all rules. If you have a rule that specifies that someone is an approver for all accounts, then that person will be automatically added to all requests, even if there are more detailed rules for specific accounts or permission sets.
35+
The approval decision and final list of reviewers will be calculated dynamically based on the aggregate of all rules. Approvers from both `Approvers` and `ApproverGroups` are combined. If you have a rule that specifies that someone is an approver for all accounts, then that person will be automatically added to all requests, even if there are more detailed rules for specific accounts or permission sets.
3536

3637
## Single Approver
3738

38-
If there is only one approver and AllowSelfApproval is not set to true, nobody will be able to approve the request.
39+
If there is only one approver (whether from `Approvers` or `ApproverGroups`) and AllowSelfApproval is not set to true, nobody will be able to approve the request.
3940

4041
## Request Processing Diagram
4142

@@ -110,6 +111,7 @@ Require explicit approval for production admin access:
110111
"Resource" : ["prod_account_id", "prod_account_id2"],
111112
"PermissionSet" : "AdministratorAccess",
112113
"Approvers" : ["manager@corp.com", "ciso@corp.com"],
114+
"ApproverGroups" : ["SAZ94GDB8"], # Slack usergroup ID for the SRE team
113115
"ApprovalIsNotRequired" : false,
114116
"AllowSelfApproval" : false,
115117
}

docs/DEPLOYMENT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ settings:
260260
| `chat:write` | Post messages to Slack |
261261
| `users:read.email` | Find user's email address for AWS account assignments |
262262
| `users:read` | Read user info for mentions in requests |
263-
| `channels:history` | Find old messages for "discard button" events |
263+
| `channels:history` | Find old messages for request expiration events |
264264
| `channels:read` | Determine if requester is in the channel |
265265
| `groups:read` | Same as above but for private channels |
266266
| `im:write` | Send direct messages to users not in the channel |

docs/FEATURES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ All events include `application: "aws-sso-elevator"` as a property.
214214
|-------|---------|------------|
215215
| `aws_access_requested` | User submits access request | account_id, permission_set, requester_email, granted, duration_hours |
216216
| `aws_access_approved` | Access approved | account_id, permission_set, approver_email, duration_hours, self_approved |
217-
| `aws_access_denied` | Request denied/discarded | account_id, permission_set, approver_email, requester_email |
217+
| `aws_access_denied` | Request denied | account_id, permission_set, approver_email, requester_email |
218218
| `aws_access_revoked_early` | Early revocation | account_id, permission_set, revoker_email, reason |
219219
| `aws_group_access_requested` | Group access request | group_id, group_name, requester_email |
220220
| `aws_group_access_approved` | Group access approved | group_id, group_name, duration_hours, self_approved |

docs/GROUP_ACCESS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ group_config = [
3232
"Resource" : ["44445555-3333-2222-1111-555557777777"], #ProdAdminAccess
3333
"Approvers" : [
3434
"email@gmail.com"
35-
]
35+
],
36+
"ApproverGroups" : ["SAZ94GDB8"], # Slack usergroup ID
3637
},
3738
]
3839
```

examples/complete/main.tf

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,11 @@ module "aws_sso_elevator" {
7171
target_prefix = "some_prefix_for_access_logs"
7272
}
7373

74-
# "Resource", "PermissionSet", "Approvers" can be a string or a list of strings
74+
# "Resource", "PermissionSet", "Approvers", "ApproverGroups" can be a string or a list of strings
7575
# "Resource" & "PermissionSet" can be set to "*" to match all
7676

7777
# Request will be approved automatically if:
78-
# - "AllowSelfApproval" is set to true, and requester is in "Approvers" list
78+
# - "AllowSelfApproval" is set to true, and requester is in "Approvers" list or a member of an "ApproverGroups" group
7979
# - "ApprovalIsNotRequired" is set to true
8080

8181
# If there is only one approver, and "AllowSelfApproval" isn't set to true, nobody will be able to approve the request
@@ -112,6 +112,7 @@ module "aws_sso_elevator" {
112112
"Resource" : "account_id",
113113
"PermissionSet" : ["ReadOnlyPlus", "AdministratorAccess"],
114114
"Approvers" : ["email@gmail.com"],
115+
"ApproverGroups" : ["SAZ94GDB8"], # Slack usergroup ID
115116
"AllowSelfApproval" : true,
116117
},
117118
{

src/access_control.py

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import datetime
22
from enum import Enum
3-
from typing import FrozenSet
3+
from typing import Callable, FrozenSet
44

55
import boto3
66

@@ -36,6 +36,7 @@ class AccessRequestDecision(BaseModel):
3636
reason: DecisionReason
3737
based_on_statements: FrozenSet[Statement] | FrozenSet[GroupStatement]
3838
approvers: FrozenSet[str] = frozenset()
39+
approver_groups: FrozenSet[str] = frozenset()
3940

4041

4142
def determine_affected_statements(
@@ -65,6 +66,8 @@ def make_decision_on_access_request( # noqa: PLR0911, PLR0913
6566
group_id: str | None = None,
6667
user_group_ids: set[str] | None = None,
6768
permission_set_arn: str | None = None,
69+
requester_slack_id: str | None = None,
70+
approver_group_resolver: Callable[[frozenset[str]], set[str]] | None = None,
6871
) -> AccessRequestDecision:
6972
"""Make a decision on an access request.
7073
@@ -78,6 +81,9 @@ def make_decision_on_access_request( # noqa: PLR0911, PLR0913
7881
If None, group-based filtering is skipped (backwards compatible).
7982
If an empty set, only statements without required_group_membership will match.
8083
permission_set_arn: The ARN of the permission set (optional, for matching ARN-based config).
84+
requester_slack_id: The Slack ID of the requester (for self-approval via group membership).
85+
approver_group_resolver: Function that resolves approver group IDs to Slack user IDs.
86+
Used for checking self-approval eligibility via group membership.
8187
"""
8288
# Filter statements by user's group membership eligibility if user_group_ids provided
8389
# This is only applicable to Statement (not GroupStatement)
@@ -88,9 +94,21 @@ def make_decision_on_access_request( # noqa: PLR0911, PLR0913
8894

8995
decision_based_on_statements: set[Statement] | set[GroupStatement] = set()
9096
potential_approvers = set()
97+
potential_approver_groups: set[str] = set()
98+
99+
# Helper to check if requester is an approver for a statement (individual or via group)
100+
def is_requester_approver_for_statement(stmt: Statement | GroupStatement) -> bool:
101+
if requester_email in stmt.approvers:
102+
return True
103+
# Check group membership if resolver is available
104+
if requester_slack_id and approver_group_resolver and stmt.approver_groups:
105+
group_user_ids = approver_group_resolver(stmt.approver_groups)
106+
if requester_slack_id in group_user_ids:
107+
return True
108+
return False
91109

92110
explicit_deny_self_approval = any(
93-
statement.allow_self_approval is False and requester_email in statement.approvers for statement in affected_statements
111+
statement.allow_self_approval is False and is_requester_approver_for_statement(statement) for statement in affected_statements
94112
)
95113
explicit_deny_approval_not_required = any(statement.approval_is_not_required is False for statement in affected_statements)
96114

@@ -101,7 +119,8 @@ def make_decision_on_access_request( # noqa: PLR0911, PLR0913
101119
reason=DecisionReason.ApprovalNotRequired,
102120
based_on_statements=frozenset([statement]), # type: ignore # noqa: PGH003
103121
)
104-
if requester_email in statement.approvers and statement.allow_self_approval and not explicit_deny_self_approval:
122+
# Check self-approval: requester must be an approver (individual or via group)
123+
if is_requester_approver_for_statement(statement) and statement.allow_self_approval and not explicit_deny_self_approval:
105124
return AccessRequestDecision(
106125
grant=True,
107126
reason=DecisionReason.SelfApproval,
@@ -110,6 +129,7 @@ def make_decision_on_access_request( # noqa: PLR0911, PLR0913
110129

111130
decision_based_on_statements.add(statement) # type: ignore # noqa: PGH003
112131
potential_approvers.update(approver for approver in statement.approvers if approver != requester_email)
132+
potential_approver_groups.update(statement.approver_groups)
113133

114134
if not decision_based_on_statements:
115135
return AccessRequestDecision(
@@ -118,7 +138,7 @@ def make_decision_on_access_request( # noqa: PLR0911, PLR0913
118138
based_on_statements=frozenset(decision_based_on_statements),
119139
)
120140

121-
if not potential_approvers:
141+
if not potential_approvers and not potential_approver_groups:
122142
return AccessRequestDecision(
123143
grant=False,
124144
reason=DecisionReason.NoApprovers,
@@ -129,6 +149,7 @@ def make_decision_on_access_request( # noqa: PLR0911, PLR0913
129149
grant=False,
130150
reason=DecisionReason.RequiresApproval,
131151
approvers=frozenset(potential_approvers),
152+
approver_groups=frozenset(potential_approver_groups),
132153
based_on_statements=frozenset(decision_based_on_statements),
133154
)
134155

@@ -172,11 +193,38 @@ def make_decision_on_approve_request( # noqa: PLR0913
172193
account_id: str | None = None,
173194
group_id: str | None = None,
174195
permission_set_arn: str | None = None,
196+
approver_slack_id: str | None = None,
197+
approver_group_resolver: Callable[[frozenset[str]], set[str]] | None = None,
175198
) -> ApproveRequestDecision:
199+
"""Make a decision on an approval request.
200+
201+
Args:
202+
action: The action being taken (Approve or Deny)
203+
statements: The statements to evaluate
204+
approver_email: Email of the approver
205+
requester_email: Email of the requester
206+
permission_set_name: Name of the permission set
207+
account_id: AWS account ID
208+
group_id: SSO group ID for group-based access
209+
permission_set_arn: ARN of the permission set
210+
approver_slack_id: Slack ID of the approver (for checking group membership)
211+
approver_group_resolver: Function that resolves approver group IDs to Slack user IDs.
212+
Takes a frozenset of group IDs and returns a set of Slack user IDs.
213+
Resolution is done per-statement to prevent cross-statement authorization bypass.
214+
"""
176215
affected_statements = determine_affected_statements(statements, account_id, permission_set_name, group_id, permission_set_arn)
177216

178217
for statement in affected_statements:
179-
if approver_email in statement.approvers:
218+
is_individual_approver = approver_email in statement.approvers
219+
220+
# Check if approver is in THIS statement's approver groups (not globally)
221+
is_group_approver = False
222+
if approver_slack_id is not None and approver_group_resolver is not None and statement.approver_groups:
223+
# Resolve only THIS statement's groups
224+
statement_group_user_ids = approver_group_resolver(statement.approver_groups)
225+
is_group_approver = approver_slack_id in statement_group_user_ids
226+
227+
if is_individual_approver or is_group_approver:
180228
is_self_approval = approver_email == requester_email
181229
if is_self_approval and statement.allow_self_approval or not is_self_approval:
182230
return ApproveRequestDecision(

src/config.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def to_set_if_list_or_str(v: list | str) -> frozenset[str]:
8383
"permission_set": to_set_if_list_or_str(_dict["PermissionSet"]),
8484
"resource": to_set_if_list_or_str(_dict["Resource"]),
8585
"approvers": to_set_if_list_or_str(_dict.get("Approvers", set())),
86+
"approver_groups": to_set_if_list_or_str(_dict.get("ApproverGroups", set())),
8687
"resource_type": _dict.get("ResourceType"),
8788
"approval_is_not_required": _dict.get("ApprovalIsNotRequired"),
8889
"allow_self_approval": _dict.get("AllowSelfApproval"),
@@ -101,6 +102,7 @@ def to_set_if_list_or_str(v: list | str) -> frozenset[str]:
101102
{
102103
"resource": to_set_if_list_or_str(_dict["Resource"]),
103104
"approvers": to_set_if_list_or_str(_dict.get("Approvers", set())),
105+
"approver_groups": to_set_if_list_or_str(_dict.get("ApproverGroups", set())),
104106
"approval_is_not_required": _dict.get("ApprovalIsNotRequired"),
105107
"allow_self_approval": _dict.get("AllowSelfApproval"),
106108
}
@@ -160,7 +162,7 @@ class Config(BaseSettings):
160162

161163
waiting_result_emoji: str = ":large_yellow_circle:"
162164
bad_result_emoji: str = ":red_circle:"
163-
discarded_result_emoji: str = ":white_circle:"
165+
denied_result_emoji: str = ":white_circle:"
164166

165167
# Status badges for Slack messages
166168
pending_status: str = ":hourglass_flowing_sand: *AWAITING APPROVAL*"

src/entities/slack.py

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

66
class ApproverAction(Enum):
77
Approve = "approve"
8-
Discard = "discard"
8+
Deny = "deny"
99
EarlyRevoke = "early_revoke"
1010

1111

0 commit comments

Comments
 (0)