<?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: Margaret Fafa Amanfu</title>
    <description>The latest articles on DEV Community by Margaret Fafa Amanfu (@megphapha).</description>
    <link>https://dev.to/megphapha</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1252710%2Ffada7d13-72a1-49c2-855b-2ddc585b57ca.jpeg</url>
      <title>DEV Community: Margaret Fafa Amanfu</title>
      <link>https://dev.to/megphapha</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/megphapha"/>
    <language>en</language>
    <item>
      <title>Where are Mali's schools most at risk? Building an Education Disruption Index from open data.</title>
      <dc:creator>Margaret Fafa Amanfu</dc:creator>
      <pubDate>Sat, 07 Nov 2026 11:02:14 +0000</pubDate>
      <link>https://dev.to/megphapha/where-are-malis-schools-most-at-risk-building-an-education-disruption-index-from-open-data-l2m</link>
      <guid>https://dev.to/megphapha/where-are-malis-schools-most-at-risk-building-an-education-disruption-index-from-open-data-l2m</guid>
      <description>&lt;p&gt;In conflict-affected countries, education is rarely disrupted by a single event. Schools close after attacks, families move away, roads become unsafe, and teachers stop coming. Organisations trying to keep children learning have limited money and limited field staff, so they face a blunt question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Where is education access most likely to be disrupted, and where should we look first?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I built the &lt;strong&gt;Mali Education Disruption Index (EDI)&lt;/strong&gt; to give a first, data-driven answer to that question. It scores all 50 of Mali's &lt;em&gt;cercles&lt;/em&gt; (the country's second-level administrative areas, roughly a district) and puts the result on an interactive map.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🗺️ &lt;strong&gt;Live map:&lt;/strong&gt; &lt;a href="https://megphapha.github.io/EduDisruption-Index/" rel="noopener noreferrer"&gt;https://megphapha.github.io/EduDisruption-Index/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💻 &lt;strong&gt;Code and data:&lt;/strong&gt; &lt;a href="https://github.com/MegPhapha/EduDisruption-Index" rel="noopener noreferrer"&gt;https://github.com/MegPhapha/EduDisruption-Index&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg24nqiw83w81a2l4013s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg24nqiw83w81a2l4013s.png" alt="The Mali EDI map: cercles coloured by priority tier, with a story panel on the left and a legend on the right" width="800" height="387"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The finished map: each of Mali's 50 cercles coloured by priority tier, with the five Critical cercles labelled by name.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This post walks through the data, the messy parts of cleaning it, the scoring formula, how I tried to stay honest about missing data, and one thing the finished map revealed about my own formula.&lt;/p&gt;
&lt;h2&gt;
  
  
  The data: three open sources
&lt;/h2&gt;

&lt;p&gt;Everything comes from public datasets on the Humanitarian Data Exchange (HDX):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Period&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Conflict events and fatalities&lt;/td&gt;
&lt;td&gt;ACLED&lt;/td&gt;
&lt;td&gt;2020–2024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;School status (open / closed)&lt;/td&gt;
&lt;td&gt;OCHA Mali school registry&lt;/td&gt;
&lt;td&gt;2023 snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Population per cercle&lt;/td&gt;
&lt;td&gt;OCHA Mali admin-2 estimates&lt;/td&gt;
&lt;td&gt;2020&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across Mali, ACLED records &lt;strong&gt;6,471 conflict events&lt;/strong&gt; and &lt;strong&gt;17,941 fatalities&lt;/strong&gt; for 2020–2024. Of the &lt;strong&gt;4,491 schools&lt;/strong&gt; I could match to a cercle, &lt;strong&gt;63% were recorded as closed&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The messy part: getting three files to agree
&lt;/h2&gt;

&lt;p&gt;Joining three datasets on a place name sounds easy. It wasn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Names don't match.&lt;/strong&gt; The same cercle can appear as &lt;code&gt;Niafunké&lt;/code&gt;, &lt;code&gt;Niafunke&lt;/code&gt; or &lt;code&gt;NIAFUNKE&lt;/code&gt; depending on the file. Every join goes through a small normalisation function that lowercases and strips French accents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;replacements&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;é&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;e&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;è&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;e&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ê&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;e&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ë&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;e&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;à&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;â&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ä&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ô&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;o&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ö&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;o&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;î&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;i&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ï&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;i&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;û&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;u&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ü&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;u&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ù&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;u&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ç&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;c&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;accented&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plain&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;replacements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accented&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Matching also checks longer names first, so &lt;code&gt;Tin-Essako&lt;/code&gt; is tried before shorter names that could match part of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The spreadsheets fight back.&lt;/strong&gt; The school registry has three rows of metadata above its real header row. Rather than add pandas and openpyxl as dependencies, I treated the &lt;code&gt;.xlsx&lt;/code&gt; for what it is, a ZIP archive of XML files, and read it with the standard library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ZipFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;data/raw/mali_schools_2023.xlsx&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;xl/sharedStrings.xml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;strings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.//{...}t&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="c1"&gt;# ...then walk the sheet's &amp;lt;row&amp;gt; elements, skipping the metadata rows
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. ACLED's summary file is "sparse".&lt;/strong&gt; The cercle and year columns are filled in only on the first row of each group; the rows below leave them blank. A stateful reader carries the last-seen values forward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cur_c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cur_y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;C&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;          &lt;span class="c1"&gt;# new cercle starts here
&lt;/span&gt;        &lt;span class="n"&gt;cur_c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;match_cercle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;C&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;H&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;          &lt;span class="c1"&gt;# new year starts here
&lt;/span&gt;        &lt;span class="n"&gt;cur_y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;H&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cur_c&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;cur_y&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;year_start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur_y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;year_end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cur_c&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;events&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cur_c&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;fatalities&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;J&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole pipeline, cleaning plus scoring plus generating the web pages, is about 770 lines of Python with &lt;strong&gt;no third-party dependencies&lt;/strong&gt;. That was deliberate: anyone can clone the repo and rerun it without setting up an environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formula
&lt;/h2&gt;

&lt;p&gt;Each cercle gets a score between 0 and 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EDI = 0.50 × closure rate
    + 0.25 × conflict events per 100k people
    + 0.25 × fatalities per 100k people
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each input is divided by the highest value of any cercle, so all three sit on a 0–1 scale before weighting.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;School closures (50%)&lt;/strong&gt; get the most weight because they measure the outcome itself: are schools working or not?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conflict events per 100k (25%)&lt;/strong&gt; capture what closure data misses, such as unsafe routes, displacement and fear of attending. Dividing by population stops big cercles winning on volume alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fatalities per 100k (25%)&lt;/strong&gt; add severity. One attack with many casualties is a different signal from many minor incidents.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Being honest about missing data
&lt;/h2&gt;

&lt;p&gt;The biggest problem in this project wasn't code. It was coverage. Automated matching linked schools to only some cercles:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Cercles&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;10 or more schools matched&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;3–9 matched&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;1–2 matched&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conflict-only&lt;/td&gt;
&lt;td&gt;none matched&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That creates a trap. A cercle with &lt;strong&gt;one&lt;/strong&gt; matched school that happens to be closed shows a "100% closure rate" and shoots up the ranking on the strength of a single data point.&lt;/p&gt;

&lt;p&gt;Rather than hide this, I made it part of the output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data-Limited tier.&lt;/strong&gt; Cercles with fewer than 3 matched schools &lt;em&gt;and&lt;/em&gt; fewer than 100 conflict events per 100k people are taken out of the ranking and shown in grey. That's 22 of the 50. The message is "we can't tell yet", not "this place is fine".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Severe conflict overrides thin data.&lt;/strong&gt; Cercles with weak school data but 100 or more events per 100k (such as Kidal and Tessalit) keep a conflict-driven tier, because that signal is strong enough to act on by itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fixed-size Critical list.&lt;/strong&gt; The top 5 cercles by score &lt;em&gt;that have reliable school data&lt;/em&gt; (3 or more matched schools) are marked &lt;strong&gt;Critical&lt;/strong&gt;. Using a fixed count instead of a score cut-off gives a short, defensible list no matter where the score boundaries fall.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it found
&lt;/h2&gt;

&lt;p&gt;Five cercles reach the Critical tier, about 856,000 people in total:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cercle&lt;/th&gt;
&lt;th&gt;Region&lt;/th&gt;
&lt;th&gt;EDI&lt;/th&gt;
&lt;th&gt;Schools closed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ménaka&lt;/td&gt;
&lt;td&gt;Gao&lt;/td&gt;
&lt;td&gt;0.557&lt;/td&gt;
&lt;td&gt;8 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tombouctou&lt;/td&gt;
&lt;td&gt;Tombouctou&lt;/td&gt;
&lt;td&gt;0.532&lt;/td&gt;
&lt;td&gt;11 of 11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bourem&lt;/td&gt;
&lt;td&gt;Gao&lt;/td&gt;
&lt;td&gt;0.522&lt;/td&gt;
&lt;td&gt;4 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bankass&lt;/td&gt;
&lt;td&gt;Mopti&lt;/td&gt;
&lt;td&gt;0.519&lt;/td&gt;
&lt;td&gt;29 of 30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ansongo&lt;/td&gt;
&lt;td&gt;Gao&lt;/td&gt;
&lt;td&gt;0.516&lt;/td&gt;
&lt;td&gt;4 of 5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They fall into three geographic clusters: the &lt;strong&gt;Niger River corridor&lt;/strong&gt; in Gao (Ménaka, Bourem, Ansongo), &lt;strong&gt;Tombouctou&lt;/strong&gt;, and Bankass on the &lt;strong&gt;central plateau&lt;/strong&gt; near the Burkina Faso border.&lt;/p&gt;

&lt;p&gt;Plotting the two main signals against each other shows where they agree. Cercles in the upper right have both high conflict and high closure rates. Cercles along the bottom edge are the data gaps: zero closures recorded, simply because no schools were matched.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffeuqdhfvlocmiqvvxyds.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffeuqdhfvlocmiqvvxyds.png" alt="Bubble chart of conflict events per 100k people against the share of schools closed, one bubble per cercle" width="800" height="397"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Conflict intensity (log scale) against school closure rate. Each bubble is a cercle, sized by population. Hollow rings mark cercles with little or no school data, which is why so many sit at 0%.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the answer depend on the time window?
&lt;/h3&gt;

&lt;p&gt;A ranking that flips when you nudge an assumption isn't much use for planning. So I reran the whole pipeline using only 2022–2024 conflict data and compared the results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four of the five Critical cercles stayed in place&lt;/strong&gt; (Ménaka, Tombouctou, Bourem, Bankass). Only the fifth slot changed: Ansongo dropped out and Diré came in. Ansongo's ranking leans on heavy conflict in 2020–2021, while Diré's 100% closure rate doesn't depend on the window. That gives field teams a four-cercle core they can trust, plus one borderline slot worth discussing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it into a map people can use
&lt;/h2&gt;

&lt;p&gt;The output is a static site on GitHub Pages: a map page and a dashboard page. A Python script writes both HTML files directly, with every number computed from the data at build time, so the text on the page can never drift from the analysis.&lt;/p&gt;

&lt;p&gt;The map uses &lt;a href="https://leafletjs.com/" rel="noopener noreferrer"&gt;Leaflet&lt;/a&gt; with cercle boundaries from &lt;a href="https://www.geoboundaries.org/" rel="noopener noreferrer"&gt;geoBoundaries&lt;/a&gt; (CC BY 4.0). A few design choices made the biggest difference:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shaded areas, not dots.&lt;/strong&gt; My first version drew one circle per cercle, placed at its main town and sized by score:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5aczd0aj49mw58kh64gl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5aczd0aj49mw58kh64gl.png" alt="The first version of the map: one coloured circle per cercle on a grey basemap" width="800" height="500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Before: one dot per cercle. It's accurate, but it's hard to see which areas are affected or how big they are.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Filling each cercle's real boundary by tier (the map at the top of this post) makes the geographic clusters jump out immediately. It also makes the data gaps visible: the big grey, dash-outlined block in the south-west is the Data-Limited tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A legend that says what to do.&lt;/strong&gt; Each tier is paired with a suggested action: Critical → &lt;em&gt;field-validate first&lt;/em&gt;, High → &lt;em&gt;next phase of assessment&lt;/em&gt;, Data-Limited → &lt;em&gt;verify data before ruling out&lt;/em&gt;. A colour scale on its own leaves the reader to work that out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A story panel.&lt;/strong&gt; A one-sentence answer, headline numbers with their sources, and cluster cards that zoom the map to those cercles when clicked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqxo4x9z2skhicoxd9lg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqxo4x9z2skhicoxd9lg.png" alt="The story panel: headline sentence, four statistics with sources, and cards for each priority cluster" width="800" height="389"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The story panel answers the question before the reader has to interpret any colours. The cluster cards cover all five Critical cercles, plus one card for the data gap.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Show the numbers behind every score.&lt;/strong&gt; Clicking a cercle opens its figures and a bar splitting the score into its three parts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ju5wm3tqmmd6bl4l8j2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ju5wm3tqmmd6bl4l8j2.png" alt="Popup for Bankass showing its numbers and a bar splitting the score into closures, events and fatalities" width="800" height="390"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Clicking Bankass shows its raw figures and how its score of 0.519 is built. Hold on to that bar; it matters later.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explain the rules on the page itself.&lt;/strong&gt; Below the map, a "How to read this map" section sets out the three indicators with their weights and sources, and the rule for every tier in plain language, so nobody has to open the methodology file to understand a colour.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd28amtf3p5tgq8jwrb1w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd28amtf3p5tgq8jwrb1w.png" alt="How to read this map section: three indicator cards with weights, tier rules with counts, and an Inspect the evidence banner" width="800" height="433"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The "How to read this map" section and the link to the repository, where every figure can be reproduced.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dashboard for the detail.&lt;/strong&gt; A second page holds the charts: the top 10 cercles, the conflict-versus-closure scatter, the tier split and conflict by region.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgvlbikn7xa1upgs44i6k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgvlbikn7xa1upgs44i6k.png" alt="Analytics dashboard with a top-10 bar chart, bubble scatter, regional bar chart, tier donut, key figures and a methodology note" width="800" height="434"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The analytics dashboard. The Critical cercles are highlighted in red in the top-10 chart.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two practical lessons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Embed your boundaries.&lt;/strong&gt; I inlined the simplified GeoJSON (rounded to 3 decimal places) straight into the page instead of fetching it at runtime. The page grew from about 75 KB to about 300 KB, but it works offline and can't break when someone else's URL changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check your basemap.&lt;/strong&gt; CARTO's free basemap tiles now need an API key, and without one they show an "API KEY REQUIRED" watermark. My live site was showing it before I noticed. I switched to Esri's light grey canvas, which still works without a key.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What the map taught me about my own formula
&lt;/h2&gt;

&lt;p&gt;That score-breakdown bar turned out to be the most useful thing I built, because it exposed a weakness in the formula.&lt;/p&gt;

&lt;p&gt;Look at Bankass above: &lt;strong&gt;0.48 of its 0.519 comes from closures, and only 0.02 and 0.01 from conflict.&lt;/strong&gt; That isn't because Bankass is peaceful. It recorded 313 conflict events and 1,170 fatalities.&lt;/p&gt;

&lt;p&gt;The cause is normalising to the maximum. One cercle, &lt;strong&gt;Abeibara&lt;/strong&gt;, a sparsely populated area in the Kidal region, has 1,072 events and 6,840 fatalities per 100,000 people. That's about 40 times the median cercle's event rate (25 per 100k). Because every cercle is divided by Abeibara's value, the conflict parts of most cercles' scores shrink to almost nothing. In practice, the "50/25/25" index ranks most of Mali by closure rate alone.&lt;/p&gt;

&lt;p&gt;The fix is straightforward: cap each indicator at a high percentile (say the 95th) before scaling, or rank on a log scale. I haven't changed it yet, because the Critical list is the part people would act on and I want to check how much it moves first. But I would never have spotted this from the ranking table alone. &lt;strong&gt;Showing how each score is built is a check on your own method, not only a courtesy to readers.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;23 of 50 cercles have no school data matched.&lt;/strong&gt; The Data-Limited tier makes this visible, but it doesn't fix it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Closure status is a snapshot.&lt;/strong&gt; The registry shows current status, not when a school closed or whether it has reopened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ACLED fatality counts are conservative&lt;/strong&gt;, and probably most under-reported in exactly the remote cercles where school data is also thin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ACLED is licensed CC BY-NC&lt;/strong&gt;, so the outputs can't be reused commercially.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No automatic refresh.&lt;/strong&gt; The pipeline runs once; ACLED publishes weekly updates that a scheduled job could pull in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The most useful finding is a disagreement
&lt;/h2&gt;

&lt;p&gt;The map is a starting point for conversation, not an answer. Where it disagrees with what education teams know on the ground, that disagreement is the most valuable result. It points either to a gap in the data or to a condition the data can't see, and either one improves the method.&lt;/p&gt;

&lt;p&gt;The approach isn't specific to Mali. Any country with ACLED coverage, a school registry and admin-level population figures can be scored the same way. The code, data, methodology and source notes are all public:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/MegPhapha/EduDisruption-Index" rel="noopener noreferrer"&gt;https://github.com/MegPhapha/EduDisruption-Index&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'd appreciate feedback, especially from anyone who has built composite indices before. How would you handle the normalisation problem?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next in the series: turning this index into a bilingual AI tutor that keeps students learning when the classroom is gone.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>opensource</category>
      <category>dataviz</category>
    </item>
    <item>
      <title>Cloud Security with AWS IAM: How I Controlled Access to EC2 Instances Using Policies, Tags, and the Policy Simulator.</title>
      <dc:creator>Margaret Fafa Amanfu</dc:creator>
      <pubDate>Mon, 22 Jun 2026 13:21:09 +0000</pubDate>
      <link>https://dev.to/megphapha/cloud-security-with-aws-iam-how-i-controlled-access-to-ec2-instances-using-policies-tags-and-the-5757</link>
      <guid>https://dev.to/megphapha/cloud-security-with-aws-iam-how-i-controlled-access-to-ec2-instances-using-policies-tags-and-the-5757</guid>
      <description>&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;Every AWS account has a question it needs to answer before anything else: &lt;em&gt;who gets access to what?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In this project, I'll use AWS Identity and Access Management (IAM) to control exactly that; setting up users, groups, and policies that define what actions are allowed and on which resources. IAM sits at the foundation of cloud security, and getting it right is what separates a well-architected environment from one that's one misconfigured permission away from a serious incident.&lt;/p&gt;

&lt;p&gt;It's a skill companies care about deeply enough that entire roles like IAM Engineers and Cloud Security Engineers exist around it. Today, I'm building that foundation from the ground up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tools and Concepts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Services I used:&lt;/strong&gt;&lt;br&gt;
Amazon EC2 to provision virtual server instances, AWS IAM to control access through users, groups, and policies, and the IAM Policy Simulator to validate permissions without touching live infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key concepts I learned:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Least privilege&lt;/strong&gt; : granting users only the access they need, nothing more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tag-based access control&lt;/strong&gt; : using &lt;code&gt;Env: development&lt;/code&gt; and &lt;code&gt;Env: production&lt;/code&gt; tags as policy conditions to enforce hard environment boundaries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IAM policy structure&lt;/strong&gt; : how Effect, Action, Resource, and Condition work together in JSON, and why explicit Deny always overrides Allow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Group-based permission management&lt;/strong&gt; : attaching policies to groups rather than individual users for scalable, consistent access control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Policy simulation&lt;/strong&gt; : validating IAM logic safely before it affects real resources.&lt;/p&gt;




&lt;h2&gt;
  
  
  Project Reflection
&lt;/h2&gt;

&lt;p&gt;This project took me approximately 2 hours to complete, including the secret mission.&lt;/p&gt;

&lt;p&gt;The most challenging part was understanding the IAM policy JSON rather than just copying and running it. Breaking down each statement; why the Condition block uses tag values to gate access, and why the explicit Deny on &lt;code&gt;DeleteTags&lt;/code&gt; and &lt;code&gt;CreateTags&lt;/code&gt; exists as a separate guardrail. It took deliberate effort to fully grasp. Getting that detail right required thinking like an attacker, not just a builder.&lt;/p&gt;

&lt;p&gt;It was most rewarding to see the policy work end-to-end: from a JSON document I dissected and understood, to a real authorization decision in the AWS console, to a confirmed simulation result in the IAM Policy Simulator. That full loop from comprehension to validation is exactly what cloud security work looks like in practice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Tags
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I did in this step
&lt;/h3&gt;

&lt;p&gt;To simulate a realistic cloud environment, I launched two EC2 instances; one tagged as a production server and one as a development server. This environment tag distinction is central to the IAM project: later, I configured policies that control which users can access which instance based on these tags, reflecting how access control works in real engineering teams where developers shouldn't have unrestricted access to production infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft107y9ppj6iynwgix6o9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft107y9ppj6iynwgix6o9.png" alt="EC2 launch screen showing Env tags" width="800" height="438"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;EC2 launch screen showing Env tags&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding tags
&lt;/h3&gt;

&lt;p&gt;Tags are key-value labels you can attach to AWS resources for organisation and management. A tag has two parts: a &lt;strong&gt;Key&lt;/strong&gt; (the category) and a &lt;strong&gt;Value&lt;/strong&gt; (the specific label). For example: &lt;code&gt;Env : production&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;They're useful for three things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Organisation&lt;/strong&gt; : filter and identify resources quickly across a large account without scrolling through everything manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost allocation&lt;/strong&gt; : track spending by team, project, or environment using AWS Cost Explorer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Policy enforcement&lt;/strong&gt; : IAM policies can condition access based on tag values, so you can allow actions on development resources while blocking the same actions on production.&lt;/p&gt;

&lt;p&gt;That third use is the focus of this project.&lt;/p&gt;

&lt;h3&gt;
  
  
  My tag configuration
&lt;/h3&gt;

&lt;p&gt;The tag key I've used on both EC2 instances is &lt;strong&gt;Env&lt;/strong&gt;, short for Environment. The values I've assigned are &lt;strong&gt;production&lt;/strong&gt; for the first instance and &lt;strong&gt;development&lt;/strong&gt; for the second, distinguishing the two environments that my IAM policies will later control access to.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: IAM Policies
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I did in this step
&lt;/h3&gt;

&lt;p&gt;Next, I created an IAM policy that grants access to the development EC2 instance only.&lt;/p&gt;

&lt;p&gt;The reasoning is straightforward: engineers testing new changes should never have unrestricted access to production infrastructure. A misconfigured script, an accidental stop command, or a wrong setting pushed during testing could take down a live environment. By scoping permissions to the development instance through an IAM policy, we enforce a hard boundary; testers can do their work freely without the risk of touching production.&lt;/p&gt;

&lt;p&gt;This is the principle of &lt;strong&gt;least privilege&lt;/strong&gt; in practice: give users exactly the access they need, and nothing more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding IAM policies
&lt;/h3&gt;

&lt;p&gt;An IAM policy is a document that defines what actions are allowed or denied on which AWS resources, and under what conditions. Every permission in AWS flows through a policy; nothing is granted by default.&lt;/p&gt;

&lt;p&gt;In plain terms: policies answer the question &lt;em&gt;"can this user do this thing to this resource?"&lt;/em&gt; They can be attached to users, groups, or roles, and they evaluate every API call made in your account against a set of Allow and Deny rules.&lt;/p&gt;

&lt;p&gt;In this project, my policy specifically allows actions on EC2 instances where the &lt;code&gt;Env&lt;/code&gt; tag equals &lt;code&gt;development&lt;/code&gt;, meaning the same user would be blocked from performing those same actions on the production instance.&lt;/p&gt;

&lt;h3&gt;
  
  
  The policy I set up
&lt;/h3&gt;

&lt;p&gt;I created the policy using the &lt;strong&gt;JSON editor&lt;/strong&gt; rather than the visual editor. JSON (JavaScript Object Notation) is the native format AWS uses to store and evaluate IAM policies. While the visual editor is a helpful starting point for beginners, writing directly in JSON gives you precise control over the policy structure, and more importantly, it's how policies are managed in real engineering workflows, through infrastructure-as-code tools like Terraform or AWS CloudFormation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8lt3xx5vakjtsfrzwxw4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8lt3xx5vakjtsfrzwxw4.png" alt="JSON policy editor" width="800" height="389"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;JSON policy editor&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Policy effect
&lt;/h3&gt;

&lt;p&gt;This policy has three statements that work together:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Allow all EC2 actions on development resources:&lt;/strong&gt; the &lt;code&gt;ec2:*&lt;/code&gt; wildcard grants full EC2 permissions, but only where the resource carries the tag &lt;code&gt;Env: development&lt;/code&gt;. Any action attempted on the production instance is implicitly denied.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Allow read-only visibility across all instances:&lt;/strong&gt; &lt;code&gt;ec2:Describe*&lt;/code&gt; lets the user see all EC2 instances regardless of environment. This is necessary so they can actually view the AWS console without getting a blank screen; but seeing is not the same as touching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Deny tag modification on all resources:&lt;/strong&gt; &lt;code&gt;ec2:CreateTags&lt;/code&gt; and &lt;code&gt;ec2:DeleteTags&lt;/code&gt; are explicitly denied across the board. This is a critical guardrail: without it, a user could simply retag the production instance as development and bypass the first rule entirely.&lt;/p&gt;

&lt;p&gt;Together, these three statements enforce a clean boundary; full working access to development, zero access to production, and no way to game the tagging system to escalate privileges.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding Effect, Action, and Resource
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;Effect&lt;/strong&gt;, &lt;strong&gt;Action&lt;/strong&gt;, and &lt;strong&gt;Resource&lt;/strong&gt; attributes of a JSON policy define the three core elements of every permission statement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Effect&lt;/strong&gt; determines whether access is granted or blocked; it can only be &lt;code&gt;Allow&lt;/code&gt; or &lt;code&gt;Deny&lt;/code&gt;. When both apply to the same action, &lt;code&gt;Deny&lt;/code&gt; always takes precedence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action&lt;/strong&gt; specifies which AWS API calls the statement covers; for example, &lt;code&gt;ec2:*&lt;/code&gt; for all EC2 actions, or &lt;code&gt;ec2:Describe*&lt;/code&gt; for read-only operations only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resource&lt;/strong&gt; defines which AWS resources the statement applies to. A &lt;code&gt;*&lt;/code&gt; targets all resources, but this can be scoped to a specific resource using its ARN (Amazon Resource Name). In this project, resource-level targeting is handled through the Condition block instead, using the &lt;code&gt;Env&lt;/code&gt; tag to distinguish development from production.&lt;/p&gt;

&lt;p&gt;Think of a policy statement as a sentence: &lt;em&gt;"[Allow/Deny] this [Action] on this [Resource]."&lt;/em&gt; The &lt;code&gt;Condition&lt;/code&gt; block is what adds the &lt;em&gt;"but only if..."&lt;/em&gt; clause, making policies precise rather than broad.&lt;/p&gt;

&lt;h3&gt;
  
  
  My JSON Policy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ec2:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"ec2:ResourceTag/Env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"development"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ec2:Describe*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ec2:DeleteTags"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ec2:CreateTags"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: Account Alias
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I did in this step
&lt;/h3&gt;

&lt;p&gt;Next, I created an &lt;strong&gt;account alias&lt;/strong&gt; for my AWS account.&lt;/p&gt;

&lt;p&gt;By default, the AWS IAM sign-in URL contains your account's numeric ID; a 12-digit number that's difficult to share and easy to mistype. An account alias replaces that with a human-readable name, making the sign-in URL clean and recognisable.&lt;/p&gt;

&lt;p&gt;This matters in practice: when onboarding a new team member or sharing console access with an intern, you want to hand them a URL like &lt;code&gt;https://intern-alias-fafa.signin.aws.amazon.com/console&lt;/code&gt; rather than a string of numbers. It's a small change with a real impact on how professionally access is managed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding account aliases
&lt;/h3&gt;

&lt;p&gt;An account alias is a custom, human-readable name assigned to your AWS account that replaces the default 12-digit account ID in the IAM sign-in URL.&lt;/p&gt;

&lt;p&gt;Instead of signing in at:&lt;br&gt;
&lt;code&gt;https://123456789012.signin.aws.amazon.com/console&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;You get:&lt;br&gt;
&lt;code&gt;https://intern-alias-fafa.signin.aws.amazon.com/console&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Beyond readability, an alias signals professionalism. It's what you'd configure in a real company environment before sharing console access with a new team member or contractor. It also reduces the risk of someone accidentally signing into the wrong account when managing multiple AWS environments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting up my account alias
&lt;/h3&gt;

&lt;p&gt;Setting up the account alias took just a few minutes; one of the quickest wins in this project. My AWS console sign-in URL is now &lt;code&gt;https://intern-alias-fafa.signin.aws.amazon.com/console&lt;/code&gt;. Clean, readable, and ready to share.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx3tbldi15pdev2y1fwe3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx3tbldi15pdev2y1fwe3.png" alt="IAM dashboard showing alias confirmed" width="799" height="266"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;IAM dashboard showing alias confirmed&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: IAM Users and User Groups
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I did in this step
&lt;/h3&gt;

&lt;p&gt;Next, I created a dedicated &lt;strong&gt;IAM user group&lt;/strong&gt; for interns and added a new &lt;strong&gt;IAM user&lt;/strong&gt; to it.&lt;/p&gt;

&lt;p&gt;Groups are how permissions scale cleanly in AWS. Rather than attaching policies to individual users, which becomes unmanageable as a team grows, you attach the policy once to the group, and every user in that group inherits it automatically. Onboard a new intern? Add them to the group. Remove someone's access? Remove them from the group. No policy editing required.&lt;/p&gt;

&lt;p&gt;The IAM user I created represents our new intern: a separate identity with its own login credentials, scoped entirely to the permissions defined by the group policy; nothing more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding user groups
&lt;/h3&gt;

&lt;p&gt;An IAM user group is a collection of IAM users who share the same permissions. Rather than attaching a policy to each user individually, you attach it once to the group and every member inherits those permissions automatically.&lt;/p&gt;

&lt;p&gt;This is what makes access management scalable. In a real engineering team, you might have groups like &lt;code&gt;developers&lt;/code&gt;, &lt;code&gt;interns&lt;/code&gt;, or &lt;code&gt;ops-team&lt;/code&gt;, each with a policy tailored to their level of access. Adding or removing a user from a group is all it takes to grant or revoke their permissions; no policy changes needed.&lt;/p&gt;

&lt;p&gt;In this project, my &lt;code&gt;interns&lt;/code&gt; group is attached to the IAM policy I created earlier, which scopes all access to the development EC2 instance only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attaching policies to user groups
&lt;/h3&gt;

&lt;p&gt;I attached the policy I created earlier to the &lt;code&gt;interns&lt;/code&gt; user group, which means every user in this group automatically inherits the same permissions; full access to the development EC2 instance, read-only visibility across all instances, and a hard block on modifying any resource tags.&lt;/p&gt;

&lt;p&gt;This is the power of group-based access management: the policy is defined once and applied consistently across every member. If I onboard five more interns tomorrow, I simply add them to the group; no need to touch the policy or configure permissions user by user.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding IAM users
&lt;/h3&gt;

&lt;p&gt;IAM users are unique identities created within an AWS account, each representing a specific person or application that needs access to AWS resources. Every IAM user has their own login credentials. Sharing the root account credentials is a serious security risk, so each user gets their own login through the account alias URL: &lt;code&gt;https://intern-alias-fafa.signin.aws.amazon.com/console&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In this project, I created an IAM user named &lt;strong&gt;fafa-dev-InternFafa&lt;/strong&gt; to represent our new intern. Their permissions are inherited directly from the &lt;code&gt;interns&lt;/code&gt; group, meaning their access is already scoped to the development environment before they log in for the first time.&lt;/p&gt;

&lt;p&gt;A key security detail: AWS displays the generated password &lt;strong&gt;only once&lt;/strong&gt; at the point of user creation; a deliberate design choice that enforces secure credential handoff rather than leaving passwords retrievable indefinitely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnpzuy7yhx45drx3ibbgh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnpzuy7yhx45drx3ibbgh.png" alt="User created / retrieve password screen" width="800" height="436"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;User created / retrieve password screen&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Logging in as an IAM User
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Sharing sign-in details
&lt;/h3&gt;

&lt;p&gt;There are two ways to share a new user's sign-in details:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Download as a .csv file:&lt;/strong&gt; directly from the AWS console at the point of user creation. This file contains the sign-in URL, username, and generated password; everything the new user needs to log in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email the sign-in instructions:&lt;/strong&gt; using the "Email sign-in instructions" option in the console. AWS generates a pre-formatted email containing the account sign-in URL and username, which you can send straight to the new user.&lt;/p&gt;

&lt;p&gt;Both options are only available immediately after user creation. Once you navigate away, the password is no longer retrievable; which is why it's critical to share credentials securely at the point of setup, not after the fact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observations from the IAM user dashboard
&lt;/h3&gt;

&lt;p&gt;Once I logged in as &lt;strong&gt;fafa-dev-InternFafa&lt;/strong&gt;, the dashboard immediately showed the policy in action. The Applications widget displayed an &lt;code&gt;Access denied to servicecatalog:ListApplications&lt;/code&gt; error, and the Cost and usage widget showed &lt;code&gt;Access denied&lt;/code&gt; across both current month and cost breakdown; services the intern has no business accessing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu4zd956vq4dtg3qrpt81.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu4zd956vq4dtg3qrpt81.png" alt="Dashboard with access denied errors" width="800" height="564"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Dashboard with access denied errors&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is exactly what we want to see. The policy attached to the &lt;code&gt;interns&lt;/code&gt; group doesn't grant broad AWS console access; it scopes permissions specifically to EC2 actions on the development instance. Any service outside that scope hits an implicit deny: IAM blocks everything that isn't explicitly allowed.&lt;/p&gt;

&lt;p&gt;Those red access denied banners aren't errors. They're the policy working as designed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Testing IAM Policies
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What I did in this step
&lt;/h3&gt;

&lt;p&gt;I logged into the AWS console as the intern IAM user and tested access against both EC2 instances; attempting actions on the production instance and the development instance separately.&lt;/p&gt;

&lt;p&gt;This is an important validation step. Creating a policy and attaching it to a group is only half the work; you need to verify that the permissions behave exactly as intended before handing credentials to a real user. A misconfigured policy that accidentally grants production access would undermine everything we've set up so far.&lt;/p&gt;

&lt;p&gt;By testing from the intern's perspective, I can confirm that the principle of least privilege is actually enforced, not just configured.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing policy actions
&lt;/h3&gt;

&lt;p&gt;I tested the policy by attempting to stop both EC2 instances as &lt;strong&gt;fafa-dev-InternFafa&lt;/strong&gt;. The results confirmed the policy is working exactly as intended.&lt;/p&gt;

&lt;p&gt;Two instances, two different tags, two completely different outcomes; from the same user, under the same policy. That's tag-based access control working in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stopping the production instance
&lt;/h3&gt;

&lt;p&gt;When I tried to stop &lt;strong&gt;Prod-server-Fafa&lt;/strong&gt; (&lt;code&gt;i-0c2e3f0f509ccdf5e&lt;/code&gt;), the action failed immediately with an authorisation error: &lt;em&gt;You are not authorized to perform this operation.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This was because the IAM policy attached to the &lt;code&gt;interns&lt;/code&gt; group only allows EC2 actions on resources tagged &lt;code&gt;Env: development&lt;/code&gt;. The production instance carries the tag &lt;code&gt;Env: production&lt;/code&gt;, so the policy's condition check fails; and AWS blocks the action entirely before it can execute.&lt;/p&gt;

&lt;p&gt;The error message itself is worth noting: it references &lt;code&gt;fafa-dev-InternFafa&lt;/code&gt; by name and explicitly states that no identity-based policy allows the &lt;code&gt;ec2:StopInstances&lt;/code&gt; action on that resource. AWS isn't just blocking the action; it's telling you exactly &lt;em&gt;why&lt;/em&gt;, which is what makes IAM errors useful for debugging in real engineering environments.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fchovk9qtu6dfr49i841g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fchovk9qtu6dfr49i841g.png" alt="Red authorisation error screen" width="799" height="205"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Red authorisation error screen&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Stopping the development instance
&lt;/h3&gt;

&lt;p&gt;Next, when I tried to stop &lt;strong&gt;Dev-server-Fafa&lt;/strong&gt; (&lt;code&gt;i-057461c1ed516a0e4&lt;/code&gt;), it worked perfectly. The console confirmed &lt;code&gt;Successfully initiated stopping of i-057461c1ed516a0e4&lt;/code&gt; and the instance state immediately shifted to &lt;code&gt;Stopping&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This was because the development instance carries the tag &lt;code&gt;Env: development&lt;/code&gt;, which satisfies the condition in the IAM policy. AWS evaluated the request, matched the tag value, and allowed the &lt;code&gt;ec2:StopInstances&lt;/code&gt; action to execute without restriction.&lt;/p&gt;

&lt;p&gt;This is the exact outcome the policy was designed to produce; full working access to development, zero access to production, enforced automatically through tags rather than manual permission checks.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1cpg6qmk7ll6obcqaeo3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1cpg6qmk7ll6obcqaeo3.png" alt="Green success banner + Stopping state" width="799" height="202"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Green success banner + Stopping state&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: IAM Policy Simulator (Secret Mission 🔐)
&lt;/h2&gt;

&lt;p&gt;To extend this project, I used the &lt;strong&gt;IAM Policy Simulator&lt;/strong&gt; to validate the intern's permissions without affecting any live AWS resources.&lt;/p&gt;

&lt;p&gt;Directly testing permissions on real infrastructure, as I did in the previous step, isn't sustainable in a production environment. Every unnecessary instance stop risks disrupting running workloads and other engineers. The Policy Simulator lets me verify exactly what a user can and cannot do against any action or resource, without executing a single real API call. It's a faster, safer, and more professional approach to permission validation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding the IAM Policy Simulator
&lt;/h3&gt;

&lt;p&gt;The IAM Policy Simulator is a tool that lets you test and validate IAM policies against specific actions and resources without executing anything in your actual AWS environment.&lt;/p&gt;

&lt;p&gt;It's useful for verifying that a policy behaves exactly as intended before it affects real infrastructure. In a real team environment where policies are updated frequently; onboarding new roles, adjusting permissions, responding to a security incident; the Policy Simulator is the difference between testing safely and finding out something is misconfigured in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  How I used the simulator
&lt;/h3&gt;

&lt;p&gt;I set up a simulation for &lt;strong&gt;FafaDevEnvironmentPolicy&lt;/strong&gt;, testing two EC2 actions; &lt;code&gt;DeleteTags&lt;/code&gt; and &lt;code&gt;StopInstances&lt;/code&gt; with the &lt;code&gt;ec2:resourcetag/env&lt;/code&gt; context key set to &lt;code&gt;development&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;StopInstances&lt;/code&gt; came back &lt;strong&gt;allowed&lt;/strong&gt;, confirming the policy permits actions on development-tagged resources. &lt;code&gt;DeleteTags&lt;/code&gt; came back &lt;strong&gt;denied&lt;/strong&gt;; the explicit Deny statement holds regardless of environment, blocking any attempt to retag resources and bypass access controls.&lt;/p&gt;

&lt;p&gt;I had to adjust the simulation context manually by adding the &lt;code&gt;ec2:resourcetag/env: development&lt;/code&gt; key-value pair under the &lt;code&gt;StopInstances&lt;/code&gt; action. Without that context, the simulator has no tag information to evaluate the condition against and would return a denied result even for actions that should be allowed. This is an important nuance: &lt;strong&gt;the Policy Simulator only knows what you tell it about the resource context.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One simulation, two actions, zero infrastructure touched; and complete confidence that the policy is enforcing the right boundaries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy91tb2i34o5d1xjsdjdy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy91tb2i34o5d1xjsdjdy.png" alt="Policy Simulator results screen" width="800" height="436"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Policy Simulator results screen&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚠️ Don't Forget to Clean Up
&lt;/h2&gt;

&lt;p&gt;If you followed along with this project, make sure to delete your AWS resources when you're done to avoid unexpected charges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terminate both EC2 instances&lt;/strong&gt; : go to EC2 &amp;gt; Instances &amp;gt; select both &amp;gt; Instance State &amp;gt; Terminate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the IAM user&lt;/strong&gt; : go to IAM &amp;gt; Users &amp;gt; select fafa-dev-InternFafa &amp;gt; Delete&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the IAM user group&lt;/strong&gt; : go to IAM &amp;gt; User Groups &amp;gt; select interns &amp;gt; Delete&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the IAM policy&lt;/strong&gt; : go to IAM &amp;gt; Policies &amp;gt; filter by Customer managed &amp;gt; select FafaDevEnvironmentPolicy &amp;gt; Delete&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove the account alias&lt;/strong&gt; — go to IAM &amp;gt; Dashboard &amp;gt; Delete next to your alias&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frp5e62w5u87740qto0s1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frp5e62w5u87740qto0s1.png" alt="Deletion/Termination of instances" width="800" height="231"&gt;&lt;/a&gt;&lt;br&gt;
_Deletion/Termination of instances _&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F56impma5rhzjkjhd1cb8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F56impma5rhzjkjhd1cb8.png" alt="IAM group deletion screen" width="799" height="151"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;IAM group deletion screen&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmwpovgoya5rvdesefeq7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmwpovgoya5rvdesefeq7.png" alt="Policy deletion screen" width="798" height="145"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Policy deletion screen&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;AWS's free tier covers most of what we used here, but EC2 instances left running will accumulate charges. When in doubt, clean up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I Did This Project
&lt;/h2&gt;

&lt;p&gt;I chose to do this project today because cloud security is a non-negotiable foundation for the DevOps engineering path I'm building toward. IAM sits at the centre of every AWS environment; understanding how to design and validate access control policies isn't optional, it's foundational.&lt;/p&gt;

&lt;p&gt;This project gave me hands-on experience with the exact skills that show up in cloud and DevSecOps job descriptions: least privilege, policy design, tag-based access control, and permission validation.&lt;/p&gt;

&lt;p&gt;This project met my goals. I came in wanting to understand IAM beyond the theory, and I'm leaving with a working policy, a tested environment, and documentation I can publish and point to.&lt;/p&gt;

&lt;p&gt;Something that would make learning with NextWork even better is more projects that chain together; where the IAM setup you build here feeds directly into a subsequent project on CloudTrail logging or AWS Config compliance rules. The security story feels most powerful when you see how the pieces connect.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>security</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
