Three Doors to MWAA Permissions: A Deep Dive into Missing Roles
“What’s in a name? That which we call a rose by any other name would smell as sweet.” — Shakespeare, Romeo and Juliet
*From “permissions open doors for people” to “some permissions exist so the code’s books balance”
During last week’s health sweep of our SIT Airflow environment, this line kept showing up in the DAG processor logs:
airflow.exceptions.AirflowException: The access_control mapping for DAG 'Example_DAG' includes a role named 'team-developers', but that role does not exist
The trend looked great. Seventy of these in the previous seven days, down to two in the last three. I wrote “residual, nearly gone, just confirm the timestamps” in my readiness notes and moved on.
A week later, the recheck showed 21 more, the newest from the night before. So I stopped reading the logs and asked the database directly:
$ airflow roles list
name
=====
Admin
Op
Public
User
Viewer
Nine custom roles, zero present. It wasn’t “nearly gone”. It had been broken since the day the environment was created.
Then I made a second wrong call. I told the team: “Without those roles, people from the other teams might not be able to see their DAGs.” Also wrong. When I read the IAM policies in Terraform, it turned out that in our MWAA setup, nobody ever logs in as team-developers, or as any of the other eight roles.
So how can a role nobody uses break anything? And if it does break something, why were all the DAGs still running? Answering both means splitting MWAA’s access control into three doors.
By the end of this post you’ll know exactly which door to check when a seemingly harmless “role not found” error keeps popping up in your managed Airflow environment, and how to confirm that your runbooks actually create those roles before the DAGs start running.
1. The map: three doors
Think of MWAA as an office building where access is controlled at three distinct layers:
| Door | In the building | In MWAA | Where it lives | Who owns it |
|---|---|---|---|---|
| 1: IAM | Lobby security – you’re let in and a badge is issued for the day. | airflow:CreateWebLoginToken policy that maps an IAM principal to an Airflow role name. |
AWS IAM (Terraform) | Cloud platform team |
| 2: FAB roles | Badge level – which doors your badge can open. | Flask‑AppBuilder role → permissions (action × resource). | Airflow metadata database. | Airflow core + the admins who configure it. |
3: DAG‑level access_control |
The booking sheet for each meeting room – who owns that room. | access_control={…} embedded in a DAG’s code, synced into permissions on DAG:<dag_id>. |
Code repository, then copied into the metadata DB during sync. | Anyone who writes or edits the DAG (the “author”). |
The column that matters for troubleshooting is “where it lives.” Door 1 sits entirely in AWS – its state never moves when you rename an environment. Doors 2 and 3 live inside Airflow’s own metadata database; if you wipe or recreate that DB, those permissions are lost and reset to the default zero state. The net effect is that users can still log into the UI (door 1 works), but they suddenly find no DAGs or access restrictions where they expect them – a classic “login succeeds, nothing else changes” symptom.
2. Door one: IAM decides which badge you wear
MWAA’s web UI login goes through CreateWebLoginToken. Here’s what our IAM policy looks like (a real Terraform config fragment):
- "airflow:CreateWebLoginToken"
resources:
- "arn:aws:airflow:ap-southeast-2:${aws_account_id}:role/${channel}-${env}/User"
- "arn:aws:airflow:ap-southeast-2:${aws_account_id}:role/${channel}-${env}-*/User"
Look at the last ARN segment: role/<environment>/<Airflow role name>. That segment decides who you are inside Airflow after login. We have exactly five of these policies, pointing at Admin, Op, User, Viewer and Public.
That gives us the first counter‑intuitive fact:
| You’d assume | What actually happens |
|---|---|
EDR engineers log in as team-developers |
They log in as User or Op |
Without team-developers, EDR engineers can’t see their DAGs |
User already has global DAG permissions and sees everything |
| Roles are for people | In our setup, the nine custom roles have zero users |
Door one has two more traps, and we hit both.
Trap one: wildcard width.
SIT’s early policy used ${env}-???. In IAM, ? matches exactly one character, so the pattern matched -251 but not -2112. When the new environment went live, the console’s link to the web UI just showed “Oops, something went wrong”, while the environment reported AVAILABLE and its last update SUCCESS. It looked like MWAA was broken. The cause was three question marks in a policy file.
Trap two: three entry points, three separate grants.
| Entry point | IAM action | Our result |
|---|---|---|
| Web UI | airflow:CreateWebLoginToken (mapped to a role) |
✅ maps to Admin |
| MWAA CLI | airflow:CreateCliToken (granted per environment) |
✅ works, but many subcommands are disabled by MWAA |
| REST API | airflow:InvokeRestApi (also needs a role mapping) |
❌ No Airflow role granted in IAM |
This table comes back in section 5, when we get to the fix.
The front door answers “who are you and which badge do you get”. It doesn’t answer “which rooms can you enter”.
3. Door two: a FAB role is a set of actions on resources
Airflow 2.x’s web UI is built on Flask‑AppBuilder (FAB). Its permission model has three fundamental concepts:
- action – the verbs that can be performed, e.g.
can_read,can_edit,can_create,can_delete, and many more. - resource – the objects those actions apply to:
DAGs,DAG Runs,Connections,Variables,Pools, etc. - permission – a single action‑on‑resource pair. A role is simply a collection of permissions.
The five default roles that ship with Airflow stack on top of each other, each inheriting the previous set plus new capabilities:
| Role | Roughly what it can do |
|---|---|
Public |
Nothing (anonymous) |
Viewer |
Read‑only: DAGs, runs, logs |
User |
Viewer + operate DAGs (pause/unpause, trigger, clear, mark state) |
Op |
User + operational config (Connections, Variables, Pools, …) |
Admin |
Everything, including managing users and roles |
Scope matters: global vs per‑DAG permissions
Resources can be global or per-object. For example:
DAGsis a global resource. Grantingcan_edit on DAGslets the holder edit every DAG in the cluster.DAG:Example_DAGis a per‑DAG resource, scoped to a single DAG namedExample_DAG. Permissions on this target only that one DAG.
The default roles all contain the global permissions. That’s why a User sees every DAG—because it inherits can_read and can_edit on the global DAGs resource. If you want to restrict a role to only a subset of DAGs, you must explicitly remove the global permission and add per‑DAG permissions for the desired DAGs.
Where the data lives
All of this information is stored in the Airflow metadata database under tables prefixed with ab_: ab_role, ab_permission_view, ab_permission_view_role, etc. When you upgrade or replace an MWAA environment, these tables are carried over with the old metadata DB; there’s no separate backup mechanism for them.
Badge levels are written in the building’s register. Demolish the building and the register goes with it.
4. Door three: access_control, a ledger that has to balance (deep dive)
4.1 What it looks like in code
Almost every one of our 200‑plus DAGs has this:
with DAG(
dag_id="Example_DAG",
schedule="15 0 * * *",
access_control={"team-developers": {"can_edit"}},
...
) as dag:
In plain terms: grant the team‑developers role the can_edit permission on the DAG identified by DAG:Example_DAG. Each team owns its own role, scoped to a folder hierarchy:
| Role | Folder | SIT refs | PRD refs |
|---|---|---|---|
team-developers |
team/ |
133 | 210 |
other-developers |
other/ |
70 | 69 |
third-developers |
third/ |
49 | 46 |
fourth-developers |
fourth/ |
1 | 5 |
air‑developers / airflow‑developers / team-test‑developers |
other | a few | a few |
special-developers |
PRD only | 0 | 1 / 2 |
The table is more than a curiosity: it tells us how many distinct roles exist across environments and gives a rough idea of how many DAGs each team owns. Notice the asymmetry between SIT (staging) and PRD (production); some teams have dozens of DAGs in production but barely any in staging, which hints at the life‑cycle practices we’ll discuss later.
4.2 How one line of config becomes an error
Here’s the call chain that surfaces when a role referenced in access_control is missing (Airflow 2.11.2 stack trace):
DAG processor parses the DAG file
└─ DagBag._serialize_dag_capturing_errors dagbag.py:693
└─ DagBag._sync_perm_for_dag dagbag.py:749
└─ security_manager.sync_perm_for_dag fab/.../override.py:1153
└─ _sync_dag_view_permissions fab/.../override.py:1220 ← raise
And the exact snippet that raises the exception (excerpted from the FAB provider 2.4.1; line numbers differ in production but logic is identical):
# Adding the access control permissions
for rolename, resource_actions in access_control.items():
role = self.find_role(rolename)
if not role:
raise AirflowException(
f"The access_control mapping for DAG '{dag_id}' includes a role named "
f"'{rolename}', but that role does not exist"
)
The code is explicit: look up the role in the database (ab_role) and abort if it’s missing. There is no implicit creation or fallback logic.
4.3 Why the DAGs kept running and the errors stayed rare
My initial interpretation of the error frequency was misleading. The stack trace order and the real‑world behaviour point to two key facts:
- Permission sync runs after the DAG is serialized into the database. Once a DAG file has been parsed, its representation is persisted as a JSON blob in the
dagtable. The scheduler later reads that blob; therefore, missing permissions do not prevent scheduling or task execution. - Sync only triggers on content change. Airflow monitors file timestamps and content hashes. If nothing changes, the DAG is considered unchanged and the permission sync step is skipped entirely.
Because of (1) and (2), a DAG that once had an invalid access_control mapping can keep running for days or weeks if no one edits it again. The count of “role does not exist” errors thus reflects how many DAGs were recently re‑serialized, not the health of the permission subsystem.
| Signal | What I thought it measured | What it actually measures |
|---|---|---|
| Count of “role does not exist” | How much of the problem is left | How many DAGs were re‑serialized recently |
| Count going down | Self‑healing | Less code churn |
| A reliable test | — | Does roles list show the role, and how many DAG permissions does it hold |
The first two checks merely confirm that no new errors surface; they do not speak to correctness. The third check shows that every role’s permission count is what we expect, and the system will reject any future attempt to grant or revoke permissions in a way that would break this balance.
4.4 It also takes permissions away
A second half of the same function, often overlooked, performs revocation:
# Revoking stale permissions for all possible DAG level resources
for perm in existing_dag_perms:
non_admin_roles = [role for role in perm.role if role.name != "Admin"]
for role in non_admin_roles:
...
if perm.action.name not in target_perms_for_role:
self.remove_permission_from_role(role, perm)
For any non‑Admin role, any DAG‑level permission that isn’t explicitly declared in access_control is removed on the next sync. A hand‑granted permission applied through the UI will be stripped away once the DAG file is parsed again.
In other words, access_control functions as a ledger rather than an ad‑hoc switch: it is the single source of truth for DAG‑level permissions. The code’s comment captures this nuance:
Every name the code writes down needs a matching entity in the database. Whether anyone uses it is a separate question.
This design choice explains why permission errors can be silent and why they reappear after seemingly innocuous edits: the ledger enforces consistency, but at the cost of accidental revocation if a role or action is omitted.
5. The fix: creating roles and forcing a sync
Missing roles, so create them. Our runbook said to use the MWAA CLI:
curl -X POST "https://$HOST/aws_mwaa/cli" -H "Authorization: Bearer $CLI_TOKEN" \
--data-raw "roles create team-developers ..."
When I actually ran it, MWAA replied:
HTTP/1.1 400 BAD REQUEST
the sub command is not allowed: create
MWAA’s CLI endpoint only allows a limited set of subcommands; roles create is not one of them. That explains why the nine roles had been missing since the cutover: the runbook contained a step that never succeeded, and nobody noticed.
The three‑entry‑points table from section 2 now comes in handy:
| Attempt | Result |
|---|---|
MWAA CLI roles create |
❌ 400, subcommand not allowed |
aws mwaa invoke-rest-api |
❌ No Airflow role granted in IAM |
| Web UI login (mapped to Admin) → Security → List Roles → add | ✅ |
In the end I used AWS CLI to create the roles directly in IAM, then logged into the MWAA web UI as an Admin and submitted FAB’s /roles/add form. It does exactly what clicking through the UI does, but it can be scripted and repeated:
aws iam create-role \
--role-name team-developers \
--assume-role-policy-document file://trust.json
# repeat for each missing role
Each role only needs a name; no base permissions are required because nobody logs in as these roles.
Once the roles exist, the DAGs that only sync on change still need a forced sync. Airflow does not expose a sync-perm command, so we trigger a re‑serialization and let the scheduler pick up the new permissions:
RESERIALIZE_DAGS = BashOperator(bash_command="airflow dags reserialize", task_id="RESERIALIZE_DAGS")
# After running this task, the scheduler will automatically sync permissions on next run.
Unpause it, trigger it, wait about a minute and a half, then pause it again.
6. Proving it’s actually fixed
After I realized that the falling‑count bug was a false alarm, I stopped treating the error counter as proof and instead ran three independent sanity checks to verify that the system behaved correctly.
| Check | Result |
|---|---|
does not exist in the RESERIALIZE_DAGS task log (580 lines) |
0 |
| “role named” in DAG processor logs after the roles were created | 0 |
can edit on DAG:* permissions held by each role |
team 133, other 70, third 49 |
The third check is decisive. The numbers 133, 70 and 49 exactly match the count of access_control entries that reference those roles in the source code, meaning the database’s permissions ledger and the code‑level ledger are perfectly aligned.
Three doors, one table
| | IAM (front door) | FAB roles (badge) | access_control (booking sheet)
|—|—|—|—|
| Question it answers | Who are you, which badge do you wear? | Which doors that badge opens? | Which team owns this room?
| Where it lives | AWS IAM | Metadata DB | Code → metadata DB
| After an env rename | Untouched | Wiped | Code survives, ledger wiped
| Typical failure | ??? wildcard too narrow | Everything beyond the defaults is gone | References a role that doesn’t exist |
| Typical misread | “MWAA is broken” | “People log in with these” | “Fewer errors means nearly fixed”
|
“You must be careful not to delete what you don’t understand.” — Unknown
My position: on MWAA with IAM‑mapped login, access_control has largely stopped doing its job of keeping people out, but you still shouldn’t delete these roles or leave them missing. The reason isn’t access control itself; it’s the ledger. If the code names a role, the database must have that entry. When you strip the ledger—say after an environment rename—you end up editing 200‑plus DAG files just to avoid creating nine empty roles. That’s a bad trade. A cleaner move is to create the roles, force a sync, reconcile the counts on the checklist for every environment cutover.
What you can do today
- Run
roles liston your MWAA environment – this command pulls the IAM roles that Airflow is configured to use. Then, in the repository that contains the DAG code, run:git grep -hoE '"[a-z-]+-developers"' -- dags/ | sort | uniq -cThe two outputs are lists of role names: one from the runtime environment and one inferred from the source code. Compare them line‑by‑line to spot any mismatches or missing roles.
-
Inspect your
CreateWebLoginTokenpolicies – locate the policy files that grant the token‑creation capability. For each statement, resolve the ARN suffix (the part after the last colon). If every ARN resolves to a default role that you know is present in MWAA’s configuration, then the issue isn’t a missing custom role; it’s a misunderstanding of what the policies actually allow. - Search for fragile wildcards in IAM policy files – patterns like
???are syntactically legal but break as soon as an environment name grows beyond three characters. Run:git grep -n '???' -- <iam-policy-dir>and review each hit. Replace them with explicit prefixes or longer wildcard ranges (e.g.,
*or?) that match the real naming scheme. -
Execute every managed‑service command listed in your runbooks – just because a CLI subcommand is documented does not guarantee it will work in MWAA’s sandboxed environment. For each command, try it against the live environment and verify the expected side effect (e.g., creating an S3 bucket, invoking a Lambda). If any fail, document the failure path and update the runbook accordingly.
- Validate permission coverage quantitatively – after applying fixes, don’t simply look for “no errors”. Count the permissions that each role actually holds using
aws iam list-attached-role-policiesor equivalent CLI calls, then compare those counts against the reference set you derived from your DAG code. This ensures that every role has exactly the rights it needs and no more.
Not every name in a permission system has a person behind it. But every name your code writes down needs something in the database that answers to it.