Skip to content

Commit 6278d4d

Browse files
authored
Merge pull request #40 from PostHog/tom/permission-set-description
Allow specifying description for permission set
2 parents afca489 + ea97303 commit 6278d4d

9 files changed

Lines changed: 237 additions & 9 deletions

File tree

CLAUDE.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,47 @@ uv run --directory src --extra dev pre-commit run --all-files
2020
```bash
2121
cd src && uv run pytest -q
2222
```
23+
24+
## Architecture Overview
25+
26+
Terraform module that deploys AWS Lambda functions for just-in-time AWS SSO access management via Slack. Two access models exist: **account access** (temporary permission set assignment on AWS accounts) and **group access** (temporary SSO group membership).
27+
28+
### Terraform Layer
29+
30+
- `vars.tf` — Module inputs. Two separate config variables: `config` (account access statements) and `group_config` (group access statements), both `type = any`.
31+
- `s3.tf` — Serializes both to a single S3 object as `{"statements": var.config, "group_statements": var.group_config}`.
32+
- `locals.tf` — Validation logic (group/attribute-sync overlap checks, extracting group names from `group_config`).
33+
- `slack_handler_lambda.tf` / `perm_revoker_lambda.tf` — Lambda function definitions. Both read config from the same S3 object.
34+
35+
### Python Layer (`src/`)
36+
37+
**Config & Models:**
38+
- `config.py` — Loads config from S3 (`load_approval_config_from_s3`), parses into `Statement`/`GroupStatement` via `parse_statement`/`parse_group_statement`. `Config` is a Pydantic `BaseSettings` singleton with `frozenset` fields for statements, accounts, groups, permission_sets.
39+
- `statement.py``BaseStatement` (has `permission_set`, `required_group_membership`, etc.) → `Statement` (account access). `GroupStatement` is separate (does not inherit `BaseStatement`, lacks `permission_set` and `required_group_membership`). Eligibility filtering (`get_eligible_statements_for_user`) currently only applies to `Statement`, not `GroupStatement`.
40+
- `events.py``RevokeEvent`/`GroupRevokeEvent` and their `Scheduled*` wrappers. Tagged union via `action` field literal types.
41+
- `entities/` — Pydantic models for AWS resources (`Account`, `PermissionSet`, `SSOGroup`) and Slack users.
42+
43+
**Request Flow (Slack → Decision → Execution):**
44+
- `main.py` — Slack bolt app. Handles account access shortcuts, modal interactions, button clicks. Registers handlers via `app.shortcut()`, `app.view()`, `app.action()`. The `handle_button_click` function falls through to `group.handle_group_button_click` on parse failure.
45+
- `group.py` — Parallel handlers for group access shortcuts, submission, and button clicks.
46+
- `slack_helpers.py` — View builders (`RequestForAccessView`, `RequestForGroupAccessView`), payload parsers (`ButtonClickedPayload`, `ButtonGroupClickedPayload`), message block builders. Modal uses dynamic view updates: accounts load first, then permission sets update on account selection via `dispatch_action=True`.
47+
- `access_control.py` — Decision logic (`make_decision_on_access_request`, `make_decision_on_approve_request`) is already unified for both Statement and GroupStatement. Execution is split: `execute_decision` (account) vs `execute_decision_on_group_request` (group).
48+
49+
**Revocation & Scheduling:**
50+
- `schedule.py``schedule_revoke_event`/`schedule_group_revoke_event` create EventBridge schedules that fire the revoker Lambda.
51+
- `revoker.py` — Handles scheduled revocations, early revocations, inconsistency checks, extend-grant button posting. Parallel functions for account and group flows.
52+
53+
**SSO Operations:**
54+
- `sso.py` — AWS SSO/Identity Store API calls. Account operations (create/delete assignment) and group operations (add/remove membership) are fundamentally different APIs. User lookup (`get_user_principal_id_by_email`) is shared.
55+
56+
**Attribute Sync (separate feature):**
57+
- `attribute_syncer.py`, `attribute_mapper.py`, `sync_state.py`, `sync_config.py`, `sync_notifications.py` — Automatic user-to-group sync based on Identity Store attributes. Runs on its own Lambda/schedule.
58+
59+
### Key Design Patterns
60+
61+
- **Config uses two separate Terraform variables** (`config` and `group_config`) that map directly to `statements` and `group_statements` in the S3 JSON. Python reads these as separate lists.
62+
- **Pydantic models use `frozen=True`** (`ConfigDict(frozen=True)` in `entities/model.py`). Extra fields are silently ignored (default Pydantic v2 behavior, no `extra="forbid"`).
63+
- **`parse_group_statement` manually extracts keys** from the raw dict (doesn't pass the whole dict to Pydantic), so extra keys like `ResourceType` in the S3 JSON are harmlessly ignored.
64+
- **Decision logic is unified** in `access_control.py` — same `make_decision_on_access_request` handles both `FrozenSet[Statement]` and `FrozenSet[GroupStatement]`.
65+
- **Execution logic is deliberately split** — account and group execution have different SSO API calls, audit fields, and scheduling.
66+
- **Tests use Hypothesis** (`src/tests/strategies.py`) for property-based testing of statement parsing alongside standard pytest.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ output "api_endpoint_url" {
189189
| <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 |
190190
| <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 |
191191
| <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 |
192+
| <a name="input_permission_set_display_names"></a> [permission\_set\_display\_names](#input\_permission\_set\_display\_names) | Optional mapping of permission set names (or ARNs) to human-friendly labels<br/>shown in the Slack dropdown. Keys are AWS SSO permission set names or ARNs<br/>(as used in config statements), values are display labels.<br/>Example: { "eks-developer" = "EKS/kubectl access", "secrets-editor" = "Manage secrets" }<br/>Permission sets without a mapping display their AWS name as-is.<br/>Note: Slack limits dropdown option text to 75 characters. | `map(string)` | `{}` | no |
192193
| <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 |
193194
| <a name="input_posthog_host"></a> [posthog\_host](#input\_posthog\_host) | PostHog host URL for analytics. | `string` | `"https://us.i.posthog.com"` | no |
194195
| <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 |

s3.tf

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ resource "aws_s3_object" "approval_config" {
6363
bucket = module.config_bucket.s3_bucket_id
6464
key = "config/approval-config.json"
6565
content = jsonencode({
66-
statements = var.config
67-
group_statements = var.group_config
66+
statements = var.config
67+
group_statements = var.group_config
68+
permission_set_display_names = var.permission_set_display_names
6869
})
6970
content_type = "application/json"
7071

src/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ class Config(BaseSettings):
145145
accounts: frozenset[str]
146146
permission_sets: frozenset[str]
147147
groups: frozenset[str]
148+
permission_set_display_names: dict[str, str]
148149

149150
s3_bucket_for_audit_entry_name: str
150151
s3_bucket_prefix_for_partitions: str
@@ -190,6 +191,7 @@ def get_accounts_and_permission_sets(cls, values: dict) -> dict: # noqa: ANN101
190191
_initial_config_etag = etag
191192
statements_raw = config_data.get("statements")
192193
group_statements_raw = config_data.get("group_statements")
194+
permission_set_display_names = config_data.get("permission_set_display_names", {})
193195
else:
194196
# Fallback to environment variables
195197
statements_raw = values.get("statements")
@@ -198,6 +200,10 @@ def get_accounts_and_permission_sets(cls, values: dict) -> dict: # noqa: ANN101
198200
group_statements_raw = values.get("group_statements")
199201
if group_statements_raw is not None and isinstance(group_statements_raw, str):
200202
group_statements_raw = json.loads(group_statements_raw)
203+
permission_set_display_names_raw = values.get("permission_set_display_names")
204+
if isinstance(permission_set_display_names_raw, str):
205+
permission_set_display_names_raw = json.loads(permission_set_display_names_raw)
206+
permission_set_display_names = permission_set_display_names_raw or {}
201207

202208
# Parse statements
203209
if statements_raw is not None:
@@ -227,6 +233,7 @@ def get_accounts_and_permission_sets(cls, values: dict) -> dict: # noqa: ANN101
227233
"statements": frozenset(statements),
228234
"group_statements": frozenset(group_statements),
229235
"groups": groups,
236+
"permission_set_display_names": permission_set_display_names,
230237
"s3_bucket_prefix_for_partitions": s3_bucket_prefix_for_partitions,
231238
}
232239

src/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,7 @@ def handle_account_selection(ack: Ack, body: dict, client: WebClient) -> SlackRe
798798
updated_view = slack_helpers.RequestForAccessView.update_with_permission_sets(
799799
view_blocks=body["view"]["blocks"],
800800
permission_sets=permission_sets,
801+
display_names=cfg.permission_set_display_names,
801802
)
802803
return client.views_update(view_id=view_id, view=updated_view)
803804

src/slack_helpers.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,17 +129,39 @@ def build_select_account_input_block(cls, accounts: list[entities.aws.Account])
129129
)
130130

131131
@classmethod
132-
def build_select_permission_set_input_block(cls, permission_sets: list[entities.aws.PermissionSet]) -> InputBlock:
133-
sorted_permission_sets = sorted(permission_sets, key=lambda permission_set: permission_set.name)
132+
def _get_permission_set_display_name(
133+
cls,
134+
ps: entities.aws.PermissionSet,
135+
display_names: dict[str, str] | None = None,
136+
) -> str:
137+
if display_names is not None:
138+
name = display_names.get(ps.name) or display_names.get(ps.arn) or ps.name
139+
else:
140+
name = ps.name
141+
return name[:75]
142+
143+
@classmethod
144+
def build_select_permission_set_input_block(
145+
cls,
146+
permission_sets: list[entities.aws.PermissionSet],
147+
display_names: dict[str, str] | None = None,
148+
) -> InputBlock:
149+
sorted_permission_sets = sorted(
150+
permission_sets,
151+
key=lambda ps: cls._get_permission_set_display_name(ps, display_names).lower(),
152+
)
134153
return InputBlock(
135154
block_id=cls.PERMISSION_SET_BLOCK_ID,
136155
label=PlainTextObject(text="Permission set"),
137156
element=StaticSelectElement(
138157
action_id=cls.PERMISSION_SET_ACTION_ID,
139158
placeholder=PlainTextObject(text="Select permission set"),
140159
options=[
141-
Option(text=PlainTextObject(text=permission_set.name), value=permission_set.arn)
142-
for permission_set in sorted_permission_sets
160+
Option(
161+
text=PlainTextObject(text=cls._get_permission_set_display_name(ps, display_names)),
162+
value=ps.arn,
163+
)
164+
for ps in sorted_permission_sets
143165
],
144166
),
145167
)
@@ -171,15 +193,20 @@ def update_with_accounts(cls, accounts: list[entities.aws.Account]) -> View:
171193
return view
172194

173195
@classmethod
174-
def update_with_permission_sets(cls, view_blocks: list, permission_sets: list[entities.aws.PermissionSet]) -> View:
196+
def update_with_permission_sets(
197+
cls,
198+
view_blocks: list,
199+
permission_sets: list[entities.aws.PermissionSet],
200+
display_names: dict[str, str] | None = None,
201+
) -> View:
175202
view = cls.build()
176203
view.submit_disabled = False # type: ignore[attr-defined]
177204
# Start from the current blocks, remove placeholder
178205
blocks = remove_blocks(view_blocks, block_ids=[cls.PERMISSION_SET_PLACEHOLDER_BLOCK_ID, cls.PERMISSION_SET_BLOCK_ID])
179206
# Insert permission set dropdown after account dropdown
180207
blocks = insert_blocks(
181208
blocks=blocks,
182-
blocks_to_insert=[cls.build_select_permission_set_input_block(permission_sets)],
209+
blocks_to_insert=[cls.build_select_permission_set_input_block(permission_sets, display_names=display_names)],
183210
after_block_id=cls.ACCOUNT_BLOCK_ID,
184211
)
185212
view.blocks = blocks

src/tests/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def pytest_sessionstart(session): # noqa: ANN201, ARG001, ANN001
5252
},
5353
]
5454
),
55+
"permission_set_display_names": json.dumps({}),
5556
}
5657
os.environ |= mock_env
5758

@@ -77,6 +78,7 @@ def mock_s3_approval_config():
7778
"AllowSelfApproval": True,
7879
}
7980
],
81+
"permission_set_display_names": {},
8082
}
8183

8284

0 commit comments

Comments
 (0)