<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Laith Rachwani</title>
    <description>The latest articles on DEV Community by Laith Rachwani (@laith_rch).</description>
    <link>https://dev.to/laith_rch</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3905767%2Fab2c2a07-e53f-49f1-a5cc-ecf87821a3a0.png</url>
      <title>DEV Community: Laith Rachwani</title>
      <link>https://dev.to/laith_rch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/laith_rch"/>
    <language>en</language>
    <item>
      <title>Learn AWS IAM by Solving 12 Policy Puzzles in the Browser</title>
      <dc:creator>Laith Rachwani</dc:creator>
      <pubDate>Thu, 21 May 2026 17:07:02 +0000</pubDate>
      <link>https://dev.to/laith_rch/learn-aws-iam-by-solving-12-policy-puzzles-in-the-browser-11bj</link>
      <guid>https://dev.to/laith_rch/learn-aws-iam-by-solving-12-policy-puzzles-in-the-browser-11bj</guid>
      <description>&lt;p&gt;AWS IAM is hard to learn from docs alone. The evaluation logic only really clicks after enough trial and error, identity policies, resource policies, SCPs, permissions boundaries, explicit deny precedence, all interacting in non-obvious ways.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://learnawsiam.com" rel="noopener noreferrer"&gt;Learn AWS IAM&lt;/a&gt; to make that process more hands-on. It's 12 interactive levels that run entirely in the browser. No AWS account, no signup, free, open source. Inspired by &lt;a href="https://learngitbranching.js.org/" rel="noopener noreferrer"&gt;Learn Git Branching&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Each level gives you a scenario: a user needs to read from one S3 bucket but not another, an EC2 role needs to assume a role in a different account, an SCP is blocking something it shouldn't. You read the existing policies, figure out what's wrong, and edit JSON until the request evaluates the way it should.&lt;/p&gt;

&lt;p&gt;Topics covered across the 12 levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Identity-based and resource-based policies&lt;/li&gt;
&lt;li&gt;Cross-account access&lt;/li&gt;
&lt;li&gt;ABAC with tags&lt;/li&gt;
&lt;li&gt;Service Control Policies&lt;/li&gt;
&lt;li&gt;Permissions boundaries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of this post is about how it's built.&lt;/p&gt;

&lt;h2&gt;
  
  
  One state machine per level
&lt;/h2&gt;

&lt;p&gt;Each level has its own XState machine. The machine owns everything: which nodes and edges exist on the canvas, which objectives are active, which UI elements are currently restricted, what popup or popover is showing. Every user action becomes an event sent to the current level's machine, whether it's submitting a policy, drawing a connection between two nodes, or clicking "next" on a tutorial popup.&lt;/p&gt;

&lt;p&gt;State machines fit because each level is a mostly linear flow with branches. Each tutorial step is a state, user actions drive transitions, and guards make sure the user can't skip ahead or reach an invalid step.&lt;/p&gt;

&lt;p&gt;The alternative I considered was a set of &lt;code&gt;useEffect&lt;/code&gt;s reacting to state changes and triggering other state changes. Ordering becomes implicit there. Each effect reacts to state, state changes trigger other effects, and you end up reasoning about which effect fires first in a given render cycle rather than reading a transition table.&lt;/p&gt;

&lt;p&gt;Machines also need to be serializable so the user's progress can be saved as checkpoints. This means runtime-dependent logic (validators, guard predicates) can't live in the machine context directly. Instead, it lives in a separate registry keyed by level number and a string name. The machine stores the name; when an action runs, it looks up the real function. Snapshot restoration works transparently because the snapshot only ever contained names.&lt;/p&gt;

&lt;h2&gt;
  
  
  The canvas is a separate layer
&lt;/h2&gt;

&lt;p&gt;The canvas renders IAM entities as draggable nodes with edges representing relationships between them. It's built on ReactFlow. The state machine is the source of truth for which nodes and edges exist; a lighter &lt;code&gt;@xstate/store&lt;/code&gt; instance handles UI-level state (positions, hover, selection).&lt;/p&gt;

&lt;p&gt;The machine emits events the canvas subscribes to: &lt;code&gt;NODES_ADDED&lt;/code&gt;, &lt;code&gt;NODES_DELETED&lt;/code&gt;, &lt;code&gt;EDGES_ADDED&lt;/code&gt;, &lt;code&gt;NODE_UPDATED&lt;/code&gt;. The canvas translates them into store actions.&lt;/p&gt;

&lt;p&gt;This indirection exists specifically because of animations. Without it, nodes deleted by the machine would vanish from the DOM immediately, before any exit animation could run. The event gives the canvas a window to animate first.&lt;/p&gt;

&lt;p&gt;The deletion flow has three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Machine emits &lt;code&gt;NODES_DELETED&lt;/code&gt; with IDs&lt;/li&gt;
&lt;li&gt;Canvas store marks those nodes as "deleting", triggering Framer Motion exit animations&lt;/li&gt;
&lt;li&gt;On animation completion, the nodes are removed from the canvas store for good.
Skipping the indirection and diffing prev/next node arrays in a &lt;code&gt;useEffect&lt;/code&gt; would technically work, but you'd lose the IDs of what was deleted the moment the machine context updates.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Policy editing with CodeMirror + AJV
&lt;/h2&gt;

&lt;p&gt;Most objectives in each level revolve around writing a policy, which is a JSON document following a specific schema. The editor is built on CodeMirror 6 with AJV validating the JSON on every keystroke.&lt;/p&gt;

&lt;p&gt;Validation runs at two levels. First, the base AWS IAM policy schema covers statement structure, Action, Principal, Resource, and condition operators. On top of that, individual objectives attach their own validation rules. Level 5's EC2 role objective, for example, requires the trust policy to include &lt;code&gt;ec2.amazonaws.com&lt;/code&gt; as the service principal.&lt;/p&gt;

&lt;p&gt;AJV compiles all the validators once when the level loads and reuses them for the lifetime of that level.&lt;/p&gt;

&lt;p&gt;The editor also has a custom CodeMirror extension that renders small help badges inline next to specific lines, attaching contextual hints to the relevant parts of the policy while the user edits it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project layout
&lt;/h2&gt;

&lt;p&gt;The codebase is organized as a stack of layers with dependencies flowing downward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lib / types / hooks / config / stores   &amp;lt;- foundation
domain                                  &amp;lt;- IAM entities, ARNs, base schemas
levels                                  &amp;lt;- per-level machines, objectives, tutorial copy
runtime                                 &amp;lt;- loads the right level, persistence, providers
features                                &amp;lt;- canvas, editor, dialogs (rendering only)
app_shell / app                         &amp;lt;- root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upper layers import from lower layers. Lower layers never import from upper ones. There is one exception: a typed pub/sub bus lets &lt;code&gt;levels/&lt;/code&gt; fire side effects handled by &lt;code&gt;runtime/&lt;/code&gt; (like saving a checkpoint) without creating an import cycle.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;features/&lt;/code&gt; and &lt;code&gt;levels/&lt;/code&gt; are also peers and can't import from each other, even though &lt;code&gt;levels/&lt;/code&gt; sits above &lt;code&gt;features/&lt;/code&gt; in the chain. Level logic and UI rendering are isolated concerns; they interact only through &lt;code&gt;runtime/&lt;/code&gt;, which acts as the bridge. The canvas component doesn't know what level it's rendering. It subscribes to whatever machine actor &lt;code&gt;runtime/&lt;/code&gt; hands it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;React with TypeScript, bundled with Vite&lt;/li&gt;
&lt;li&gt;XState for level orchestration, &lt;code&gt;@xstate/store&lt;/code&gt; for lighter state - ReactFlow for the canvas&lt;/li&gt;
&lt;li&gt;CodeMirror 6 and AJV for the editor&lt;/li&gt;
&lt;li&gt;Chakra UI for components&lt;/li&gt;
&lt;li&gt;Framer Motion for animations&lt;/li&gt;
&lt;li&gt;Vitest for unit and integration tests&lt;/li&gt;
&lt;li&gt;Playwright for E2E across all 12 levels&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each level's state machine is a separate dynamic import, so only the current level's code is downloaded when the user navigates to it. The code editor is also lazy-loaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://learnawsiam.com" rel="noopener noreferrer"&gt;https://learnawsiam.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/laythra/learn-aws-iam" rel="noopener noreferrer"&gt;https://github.com/laythra/learn-aws-iam&lt;/a&gt; (MIT)&lt;/li&gt;
&lt;li&gt;Full architecture writeup: &lt;a href="https://github.com/laythra/learn-aws-iam/blob/main/ARCHITECTURE.md" rel="noopener noreferrer"&gt;ARCHITECTURE.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer questions about the evaluator or any of the architecture decisions&lt;/p&gt;

</description>
      <category>aws</category>
      <category>showdev</category>
      <category>react</category>
      <category>xstate</category>
    </item>
  </channel>
</rss>
