Skip to main content
Version: main ๐Ÿšง

Security Review: Slack JIT Keycloak user creation

Companion to: spec.md, plan.md, research.md, tasks.md

Reviewers: Platform Engineering (self-review), security review pending external sign-off.

Status: Initial walkthrough complete; no STRIDE-class showstoppers identified at the design level. Live-verification follow-ups tracked in CHECKLIST.md.

This document is the threat-model walkthrough for the JIT path. It expands the threat catalog from spec.md ยง7 with a STRIDE breakdown and concrete mitigations (with code/test pointers).


1. Trust boundariesโ€‹

+------------------+    Slack Events API   +------------------+
| Slack workspace | โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ | slack-bot pod |
| (untrusted edge) | (HMAC-signed) | (semi-trusted) |
+------------------+ +------------------+
โ”‚
admin-client โ”‚ Keycloak Admin
bearer token โ”‚ REST API
โ–ผ
+-------------+
| Keycloak |
| (trusted) |
+-------------+
โ”‚
โ”‚ on first Duo
โ”‚ login: silent
โ”‚ IdP broker
โ”‚ auto-link
โ–ผ
+-------------+
| Duo IdP |
| (trusted) |
+-------------+

The new trust boundary introduced by this spec is the slack-bot โ†’ Keycloak Admin REST API call that creates users (previously slack-bot only read users). All STRIDE classes below target that boundary.


2. STRIDE walkthroughโ€‹

S โ€” Spoofingโ€‹

ThreatMitigationCode/Test
Slack request forged by a third party (e.g. attacker reaches the bot endpoint directly without going through Slack)Slack Events API HMAC verification on every event; misverified events are dropped before any Keycloak call.slack_bot/app.py Bolt signature verification (Bolt default), pre-existing.
slack_user_id value forged in the Slack event payloadThe bot trusts only event.user from the Slack-signed payload. The slack_user_id written to Keycloak is what Slack signed for, not what the user typed.identity_linker.py:auto_bootstrap_slack_user.
A malicious user spoofs an email by setting their Slack profile email to victim@corp.comSlack does not let users set arbitrary profile emails โ€” the email comes from the workspace's SSO/SCIM provider (in our deployments, the same Duo IdP that owns Keycloak). If the deployment uses Slack's free-form profile emails, the operator MUST set SLACK_JIT_ALLOWED_EMAIL_DOMAINS to the corporate domain only.Documented in operator-guide.md; allowlist enforced in identity_linker.py.
Compromise of Slack workspace bypasses Keycloak's IdP allowlistOut of scope: Slack workspace compromise lets the attacker DM the bot as any Slack user; the auto-merge on Duo login still binds the resulting Keycloak user to whoever later signs in via Duo for that email. The damage is bounded to "create a shell user" โ€” no realm role is granted by JIT.N/A; defended by RBAC: shell users have no realm roles, so even if created they cannot invoke any agent until a real admin assigns them a role.

T โ€” Tamperingโ€‹

ThreatMitigationCode/Test
Slack user manipulates Keycloak attributes on other users via the JIT pathHelper-shape mitigation M1: create_user_from_slack returns the new user's UUID, and the only PUT /users/{id} call inside the JIT module is bound to that UUID. There is no exported "update arbitrary user" function.keycloak_admin.py (no update_user_arbitrary helper); test test_post_users_url_targets_only_freshly_created_id.
Slack user injects realm roles into the created userThe POST body sent to Keycloak does not include realmRoles / clientRoles / groups โ€” it sets only username, email, firstName, lastName, enabled, emailVerified, and the attributes map (which Keycloak treats as user-defined data, not authorization data).keycloak_admin.py:create_jit_user body; spec FR-003.
The JIT user's attributes map is used for authorization decisions laterAuthorization in CAIPE flows from Keycloak realm roles + Authorization Services (PDP), never from arbitrary attributes. The only place an attribute is read for an auth decision is slack_user_id for linking, which is the attribute we're writing.Reviewed across the RBAC code path; documented in docs/docs/security/rbac/architecture.md.
The IdP syncMode=IMPORT lets local edits drift from the IdP foreverAcknowledged trade-off: the IdP is treated as authoritative on first import, then local attributes (including the slack_user_id we wrote during JIT) are preserved. There is no automatic drift detection; if Duo changes a user's email, Keycloak will not pick it up. Tracked as a known operational caveat in operator-guide.md.init-idp.sh (syncMode: IMPORT); see research.md D2.b.

R โ€” Repudiationโ€‹

ThreatMitigationCode/Test
A JIT-created user denies they ever interacted with SlackThe bot logs event=slack_jit_user_created with the slack_user_id (visible to Slack workspace admins via Slack's audit log) and mask_email(email) (correlatable to Keycloak) at INFO level on every JIT creation.identity_linker.py log emission; test test_log_record_event_field_is_slack_jit_user_created.
Keycloak admin denies the JIT path created a userKeycloak's built-in admin event log records CREATE_USER with clientId=caipe-platform, the new user's UUID, and the slack_user_id attribute. Admin events are forwarded to the SIEM.Pre-existing Keycloak event-listener config; unchanged by this spec.
Operator denies enabling JIT in a given environmentSLACK_JIT_CREATE_USER is read at process startup and the bot logs its current value once at INFO at boot.app.py startup banner; tests/test_keycloak_admin_config.py.

I โ€” Information disclosureโ€‹

ThreatMitigationCode/Test
Admin client secret leaks in slack-bot logsCentralized log redaction in log_redaction.py strips known secret-shaped substrings (long bearer-like tokens, KEYCLOAK_*_SECRET=... env dumps). The loguru sink is wrapped at startup so all existing log calls inherit the filter.slack_bot/utils/log_redaction.py; test suite test_log_redaction.py.
Full email addresses leak in audit logsEvery log line that needs an email uses mask_email(email). Slack IDs are similarly masked via mask_slack_id.email_masking.py; tests test_email_masking.py.
Keycloak admin token leaks in slack-bot logs (e.g. via httpx debug logging)The httpx logger is set to WARN at startup; the admin client's Authorization header is excluded from any structured log emit.app.py log config; reviewed in PR.
Slack profile data (full name, image URL) leaks via JIT-created Keycloak user representationWe only copy email and (optionally) parsed first/last name from the Slack profile to Keycloak. Profile image, status text, etc. are not propagated. The information disclosure surface is no greater than the user's existing public Slack profile.identity_linker.py:_slack_profile_to_kc_payload.

D โ€” Denial of serviceโ€‹

ThreatMitigationCode/Test
Attacker DMs the bot as N spoofed Slack users to create N Keycloak usersSlack rate-limits the bot's incoming events at the workspace level; the bot itself rate-limits per-user using the existing cooldown logic (_linking_prompt_sent). At Keycloak, caipe-platform's service-account JWT has a finite TTL and the admin endpoint is rate-limited at the proxy layer.Pre-existing cooldown logic, Slack workspace settings, Keycloak proxy rate limits.
The JIT POST to Keycloak hangs and starves the slack-bot event loopAll JIT calls use httpx.AsyncClient with explicit timeouts (5s connect, 10s read, total deadline 15s). Failures fall through to the link-based onboarding fallback rather than blocking the user indefinitely.keycloak_admin.py httpx config; falls through in identity_linker.py exception handler.
Mass JIT creation fills Keycloak's user tableBounded by the SLACK_JIT_ALLOWED_EMAIL_DOMAINS allowlist when configured. Operationally bounded by Keycloak's underlying database size. The KeycloakUserCreationSpike SIEM alert fires on >10 CREATE_USER events per minute originating from caipe-platform.Documented in operator-guide.md.
Slack users.info API is rate-limited and the bot hammers itThe bot caches the users.info result per Slack user for 1 hour (existing cache). The JIT branch is gated behind the same cache.slack_sdk cache layer, pre-existing.

E โ€” Elevation of privilegeโ€‹

ThreatMitigationCode/Test
A JIT-created user is automatically granted any realm roleExplicitly not the case: JIT users are created with no realmRoles, no clientRoles, no groups. Until an admin assigns them a role through the admin UI, they cannot invoke any agent (RBAC denies everything). The first Duo sign-in does not grant roles either โ€” it only links the federated identity.spec FR-003; verified by acceptance scenario T035 in tasks.md.
The slack-bot's admin client gains write access to Keycloak realm/client configThe caipe-platform service-account holds only the three realm-management client roles {view-users, query-users, manage-users}. It does NOT hold manage-realm, manage-clients, or view-events. Verified by init-idp.sh:_ensure_caipe_platform_user_roles on every chart deploy and by the periodic CI assertion (follow-up F1).init-idp.sh and the assertion script.
The slack-bot creates a user with enabled=false then someone manually enables it with elevated rolesOut of scope: any admin enabling a user and granting them roles is an authenticated admin action audited by Keycloak's own admin event log. The JIT path itself never grants elevation.N/A.
The auto-merge on Duo first login binds the JIT user to the wrong Duo identityThreat requires a collision in the email field between two distinct Duo identities. In the corporate Duo deployment Duo enforces email uniqueness, so this collision cannot happen by construction. In the partner-pilot deployment we set SLACK_JIT_ALLOWED_EMAIL_DOMAINS to the corporate domain, narrowing the namespace to the same Duo tenant.Documented in operator-guide.md; research.md D2.

3. Residual risksโ€‹

RiskSeverityOwnerTracking
caipe-platform service-account credential compromise grants enumerate + create on all realm usersMediumPlatform EngineeringMitigated by no-realm-config-write; SIEM alert on CREATE_USER spike; rotation calendar in secrets-bootstrap.md.
Operator forgets to set SLACK_JIT_ALLOWED_EMAIL_DOMAINS in a multi-organization Slack workspaceMediumOperatorDocumented in operator-guide.md; helm values comment makes this explicit; consider chart-side warning in a follow-up.
Auto-merge silently links a JIT user to a Duo identity that has the same email โ€” by IdP misconfiguration on Duo's sideLowIdentity team (Duo administration)Out of scope; bounded by Duo's email uniqueness invariant.
syncMode=IMPORT means a Duo email change is not propagated to KeycloakLowOperatorCaveat in operator-guide.md; future spec (105) may add a periodic resync job.

4. Test coverage mapโ€‹

ThreatTest
S โ€” slack_user_id forgedtest_identity_linker_jit.py::test_jit_uses_slack_signed_user_id
T โ€” wrong-user PUTtest_keycloak_admin_jit.py::test_post_users_url_targets_only_freshly_created_id
T โ€” realm role injectiontest_keycloak_admin_jit.py::test_create_user_from_slack_posts_correct_body (asserts no realmRoles/groups/clientRoles)
R โ€” audit log presencetest_identity_linker_jit.py::test_log_record_event_field_is_slack_jit_user_created
I โ€” secret in logstest_keycloak_admin_jit.py::test_create_user_from_slack_secret_never_in_logs
I โ€” email maskingtest_email_masking.py::*, test_log_redaction.py::*
D โ€” JIT 401 fallbacktest_identity_linker_jit.py::test_jit_on_create_user_401_returns_none_logs_warning
D โ€” JIT 403 fallbacktest_identity_linker_jit.py::test_jit_on_create_user_403_returns_none_logs_warning
E โ€” domain allowlisttest_identity_linker_jit.py::test_jit_domain_allowlist_excludes_non_listed_domain
E โ€” admin unconfigured fallbacktest_identity_linker_jit.py::test_jit_on_admin_unconfigured_warns_once_returns_none

5. Sign-offโ€‹

  • STRIDE walkthrough complete (this document).
  • All threats above have at least one mitigation and at least one automated test (see ยง4).
  • Residual risks documented with owners.
  • External security-team sign-off (request opened separately).
  • Live-verification of T031โ€“T035 in tasks.md (tracked in CHECKLIST.md for in-person Slack DM steps).

Assisted-by: Claude:claude-opus-4-7