---
title: 'Access Control Lists'
source: 'https://academia.sh/en/courses/wireless-and-security/access-control-lists'
course: 'Wireless Networks and Network Security'
language: en
updated: '2026-08-17T18:07:21+00:00'
license: 'CC BY-SA 4.0'
---

# Access Control Lists

Four rule sets on the same forty flows: the seven-rule narrow set gives 40/40 correct, the six-rule wide set false-admits 2 flows, the eight-rule misordered set also admits 2 and shadows 1 rule, and the seven-rule tightened set zeroes the leak while false-denying 2 flows.

The previous lesson held the chain fixed and changed the mechanism running it. With seven
rules, the stateful mechanism gave the same decision as intent on all forty flows — but we
never showed that those seven rules were the right seven. It was taken on faith.

This lesson does the reverse: the mechanism is fixed, and what changes is the chain
itself. An **access control list** is an ordered sequence of (action, criteria) pairs; the
criteria requires all the fields it writes to hold at once, and a field it does not write
matches any value. Its question is this: can we tell, just by looking at a rule set, how
well it meets intent?

## Two Things That Give the Chain Meaning

**Evaluation order** is as decisive as the criteria itself. The flow enters the chain from
the top, the first matching rule's action is applied, and the chain ends there. Arranging
the same rules in a different order draws a different boundary.

**Implicit deny** stops at wherever the chain ends and is never written. Every rule in all
four sets in this lesson has the **permit** action; the only thing that blocks is the
implicit deny. This has a measurement consequence: every blocked flow is blocked from a
single place, a place with no name.

The Perimeter Defense topic of the Cybersecurity curriculum taught the **syntax** of
access control lists — which field goes where, how a list is built. That syntax is not
repeated here. What is measured is not the list's syntax but **the semantics of an ordered
chain itself**: where the chain departs from intent in both directions at once.

Three more sets join the previous lesson's seven-rule **narrow** set.

```text
# taught rule dump , not executed
# the syntax is fictional and matches no device's actual format

wide (6) : the narrow set's first two rules collapsed into one
  1  permit  ext  dmz  *          <- 80 and 443, instead of separately
  2..6 = narrow set's rules 3..7

misordered (8) : a rule added to the front of the narrow set
  1  permit  dmz  *    *          <- new , at the very front of the chain
  2..8 = narrow set's rules 1..7

tightened (7) : the narrow set's rule 6 narrowed
  6  permit  int  ext  443        <- port 443 only, instead of every port
```

## Rule Shadowing

The change in the third set makes one rule **unreachable**. When a rule that permits every
flow sourced from `dmz` is placed at the front of the chain, no flow can reach the "only
`dmz` to `int`, port 3306" rule below it — because every flow that could reach it has
already matched above. This is called **rule shadowing**.

The definition is **static**: a rule is shadowed if it is fully contained by an earlier
rule's criteria. This can be said without looking at traffic at all, just by looking at
the chain. Why the distinction matters will be seen after the measurement.

The measurement's assumptions:

- **NS8** — The forty flows are taken unchanged from the shared fixture; their source,
  destination, and port come from the fixture. The oracle is intent itself, known because
  we wrote the fixture.
- **NS9** — All four sets run on the same mechanism: first match wins, the chain ends in
  implicit deny, every flow counts as a new connection. The previous lesson's phase
  distinction is absent here.
- **NS10** — A criterion a rule does not write matches any value; the criteria it does
  write must all hold at once.
- **NS11** — All four sets' rules carry the **permit** action. The only thing that blocks
  is the implicit deny; this concentrates the source of every false deny in one place.
- **NS12** — Rule shadowing is defined statically and does not look at traffic; the match
  counter looks only at traffic. The two are measured separately.
- **NS13** — The match counter counts where the chain **cuts off**: a flow stops at the
  first rule it matches and never enters the rules after it.
- **NS14** — In a set of forty events, the smallest measurable difference is
  **1/40 = 0.025**.

## Measurement

```python
"""Access control lists: rule count, evaluation order, shadowing.

Part 1 - four sets on the same forty flows.
Part 2 - which rule matched how many flows, how many fell to implicit deny.
"""
SEED = 20260811
ZONES = ("ext", "dmz", "int", "mgmt")
PORTS = (80, 443, 22, 3306, 8080)


def generator(seed):
    d = seed % 2147483646 + 1

    def r(n):
        nonlocal d
        d = (d * 48271) % 2147483647
        return d % n
    return r


def flows(count=40, seed=SEED):
    r, out = generator(seed), []
    for i in range(count):
        out.append({"no": i + 1, "source": ZONES[r(4)],
                    "dest": ZONES[r(4)], "port": PORTS[r(5)]})
    return out


def policy(a):
    """Intent: only the DMZ's web entry from outside; nobody from outside to mgmt."""
    if a["source"] == "ext":
        return a["dest"] == "dmz" and a["port"] in (80, 443)
    if a["source"] == "dmz":
        return a["dest"] == "int" and a["port"] == 3306
    if a["source"] == "mgmt":
        return True
    return a["dest"] != "mgmt"


def diff(events, intent, device):
    d = {"correct_pass": 0, "correct_block": 0, "false_admit": 0, "false_deny": 0}
    for o in events:
        n, g = intent(o), device(o)
        if n and g:
            d["correct_pass"] += 1
        elif not n and not g:
            d["correct_block"] += 1
        elif g:
            d["false_admit"] += 1
        else:
            d["false_deny"] += 1
    return d


def rule_chain(rules):
    """First match wins; implicit deny at the end."""
    def run(a):
        for action, criteria in rules:
            if all(a[k] == v for k, v in criteria.items()):
                return action
        return False
    return run


def shadowed_rules(rules):
    """The index of every rule fully covered by a rule ahead of it."""
    out = []
    for i, (_, criteria) in enumerate(rules):
        for _, earlier in rules[:i]:
            if all(criteria.get(k) == v for k, v in earlier.items()):
                out.append(i)
                break
    return out


def shadowed_count(rules):
    return len(shadowed_rules(rules))


def trace(rules, ak):
    """How many flows each rule matched, and how many fell to implicit deny."""
    counts, denied = [0] * len(rules), 0
    for a in ak:
        for i, (_, criteria) in enumerate(rules):
            if all(a[k] == v for k, v in criteria.items()):
                counts[i] += 1
                break
        else:
            denied += 1
    return counts, denied


NARROW = [(True, {"source": "ext", "dest": "dmz", "port": 80}),
          (True, {"source": "ext", "dest": "dmz", "port": 443}),
          (True, {"source": "dmz", "dest": "int", "port": 3306}),
          (True, {"source": "mgmt"}),
          (True, {"source": "int", "dest": "dmz"}),
          (True, {"source": "int", "dest": "ext"}),
          (True, {"source": "int", "dest": "int"})]

WIDE = [(True, {"source": "ext", "dest": "dmz"})] + NARROW[2:]
MISORDERED = [(True, {"source": "dmz"})] + NARROW
TIGHTENED = NARROW[:5] + [(True, {"source": "int", "dest": "ext", "port": 443})] + NARROW[6:]

SETS = (("narrow", NARROW), ("wide", WIDE),
        ("misordered", MISORDERED), ("tightened", TIGHTENED))
AK = flows()

print(f"flow {len(AK)} | intent passes {sum(1 for a in AK if policy(a))} "
      f"| intent blocks {sum(1 for a in AK if not policy(a))}")
print()
print(f"{'rule set':<14s} {'rules':>5s} {'shadowed':>8s} {'implicit deny':>13s} "
      f"{'correct':>7s} {'false admit':>11s} {'false deny':>11s}")
for name, k in SETS:
    d = diff(AK, policy, rule_chain(k))
    _, denied = trace(k, AK)
    print(f"{name:<14s} {len(k):5d} {shadowed_count(k):8d} {denied:13d} "
          f"{d['correct_pass'] + d['correct_block']:7d} {d['false_admit']:11d} "
          f"{d['false_deny']:11d}")

print()
print("narrow set, matches per rule:", trace(NARROW, AK)[0])
print("misordered set, matches per rule:")
counts, denied = trace(MISORDERED, AK)
shadow = shadowed_rules(MISORDERED)
for i, ((_, criteria), n) in enumerate(zip(MISORDERED, counts)):
    print(f"  {i + 1}. {str(criteria):<48s} {n:3d}"
          f"  {'shadowed' if i in shadow else ''}".rstrip())
print(f"  implicit deny{'':40s} {denied:3d}")

print()
for name, k in SETS[1:]:
    d = rule_chain(k)
    print(f"{name}: false admit",
          [(a["no"], a["source"], a["dest"], a["port"])
           for a in AK if d(a) and not policy(a)],
          "| false deny",
          [(a["no"], a["source"], a["dest"], a["port"])
           for a in AK if policy(a) and not d(a)])
```

```
flow 40 | intent passes 22 | intent blocks 18

rule set       rules shadowed implicit deny correct false admit  false deny
narrow             7        0            18      40           0           0
wide               6        0            16      38           2           0
misordered         8        1            16      38           2           0
tightened          7        0            20      38           0           2

narrow set, matches per rule: [0, 1, 0, 11, 4, 3, 3]
misordered set, matches per rule:
  1. {'source': 'dmz'}                                  2
  2. {'source': 'ext', 'dest': 'dmz', 'port': 80}       0
  3. {'source': 'ext', 'dest': 'dmz', 'port': 443}      1
  4. {'source': 'dmz', 'dest': 'int', 'port': 3306}     0  shadowed
  5. {'source': 'mgmt'}                                11
  6. {'source': 'int', 'dest': 'dmz'}                   4
  7. {'source': 'int', 'dest': 'ext'}                   3
  8. {'source': 'int', 'dest': 'int'}                   3
  implicit deny                                          16

wide: false admit [(3, 'ext', 'dmz', 3306), (18, 'ext', 'dmz', 8080)] | false deny []
misordered: false admit [(23, 'dmz', 'mgmt', 8080), (39, 'dmz', 'mgmt', 8080)] | false deny []
tightened: false admit [] | false deny [(30, 'int', 'ext', 3306), (35, 'int', 'ext', 3306)]
```

## Counting Rules Does Not Measure Policy

Reading the four rows by rule count gives the order 6, 7, 7, 8. Reading them by correct
count gives 38, 40, 38, 38. There is no link between the two orderings.

**Seven rules were written twice and gave two different results.** The narrow set is
40/40 correct; the tightened set, with the same rule count, gets 38. **Eight rules are not
better than seven:** the misordered set is one rule longer and stayed at 38. **Six rules
are not simpler than seven:** the wide set is one rule shorter and is also 38. A set's
length says nothing about its deviation from intent.

The **direction** of the deviation cannot be read from the set either. All three flawed
sets give 38 correct, but:

**Wide set: 2 false admit, 0 false deny.** A rule's port field was deleted, and two flows
going from `ext` to `dmz` — one on 3306, one on 8080 — passed although intent would have
blocked them. Rule count dropped by one; the open surface grew by two flows.

**Misordered set: 2 false admit, 0 false deny.** The rule added to the front of the chain
permits everything sourced from `dmz`; two flows from `dmz` to the management zone leaked
through this way. Yet this was intent's most definite statement: nobody from outside to
the management zone.

**Tightened set: 0 false admit, 2 false deny.** No leak here at all — flawless on paper.
The cost shows up in the other column: two flows going from `int` to `ext` were cut
because their port was not 443, and intent would have passed both. An audit that only
counts leaks cannot tell this set apart from the narrow set.

Two flows each are a share of **0.050** in a set of forty; since the smallest measurable
difference is **0.025**, both are within the measurement band.

## Zero Matches Says Two Different Things

The lower table shows the chain's operation rule by rule. Two rules in the misordered set
match **0** flows: the second rule (`ext dmz 80`) and the fourth rule (`dmz int 3306`).
The counter writes the same thing for both. Their reasons are not the same.

The second rule is reachable; none of the forty flows happens to match it. It would match
if the traffic changed. The fourth rule, however, is **unreachable**: every flow that
could reach it is cut off at the first rule. It will not match no matter what the traffic
is. The counter cannot tell these two apart; **static scanning** can, and it flags only
the fourth one in the last column. In the narrow set, too, the third rule matches 0 flows,
and there is no shadowed rule there — the same observation, a different reason.

Had the shadowed rule's action been **different** from the one shadowing it, the result
would not have been this quiet. All four sets in this lesson had every rule carry the
permit action (NS11). In a mixed chain, a **deny** rule written underneath a wide
**permit** rule is never applied even though it was written; the chain keeps open a path
its author thought was closed. Static scanning flags both cases the same way — comparing
actions is not the scan's job — but the weight of the flag is not the same: in one case
only readability suffers, in the other, policy itself breaks.

The shadowed rule itself does no harm in this measurement. It carries the same action as
the rule shadowing it, so deleting it changes no decision across the forty flows. The rule
doing the harm is the shadowing one, and its cost is paid elsewhere: in the two flows
leaking into the management zone. The shadowed rule is a **symptom**, not the leak itself.
What the symptom says is this: someone reading the chain sees the line "`dmz` to `int`,
only the data entry is open" and believes it, when two lines above, that zone has already
been opened entirely. The rule chain does not run the way it reads.

## Surfaces Left Open, and Narrowings

**First surface — a deleted criteria field.** In the wide set, deleting the port field
saved one rule and leaked two flows. **Narrowing:** write the criterion. The cost is
exactly one rule, and in exchange false admit drops from **2** to **0**. Simplification
achieved by deleting a criteria field looks like an improvement to an audit that reads
rule count.

**Second surface — a wide rule placed at the front.** When a rule opening an entire zone
is placed at the very front, it neutralizes every narrowing rule that follows it.
**Narrowing:** place rules by **criteria scope**, not by insertion order — narrowly-scoped
rules first, wide ones later — and run the chain through static scanning after every
change. The scan catches the shadowed rule in this set and points at the wide rule.

**Third surface — the silence of implicit deny.** In the tightened set, implicit deny cuts
**20** flows; **18** of them are flows intent also cuts, **2** are false denies. Both are
cut from the same place, a place with no name, and neither has a rule name standing
against it. **Narrowing:** write a rule at the end of the chain whose action is **deny**
and that leaves every criterion open. It changes no decision — implicit deny was already
doing the same thing — but it makes the blocked flow countable. False deny can only be
measured where it can be counted.

## Summary

- An access control list's meaning does not come from its criteria alone, but from the
  chain the criteria and the evaluation order build together; the chain ends in an
  unwritten implicit deny.
- On the same forty flows, the narrow set gives 40/40 correct with **7** rules; the wide
  set produces **2** false admits with **6** rules; the misordered set produces **2**
  false admits and **1** shadowed rule with **8** rules; the tightened set produces **2**
  false denies with **7** rules.
- There is no link between rule count and deviation from intent: seven rules were written
  twice, one gave 40 correct, the other 38. **Counting rules does not measure policy.**
- The direction of the deviation cannot be read from the set either; all three flawed sets
  give 38 correct, but two of them false-admit and one false-denies.
- Zero matches comes from two different causes — an unused rule and an unreachable rule —
  and the counter cannot tell them apart; rule shadowing is visible only through static
  scanning.
- A shadowed rule is a symptom; what produces the leak is the wide rule shadowing it.

## Next Step

Every rule in this lesson was written over four names: `ext`, `dmz`, `int`, `mgmt`. These
names came from the fixture and were never questioned — as if the network's zones were
just given. But the number of zones is a design decision: the same network can be split
into two zones, or six. The next lesson derives rules from a fixed intent and changes the
zone graph, measuring two things at once: how many rules are needed as the zone count
grows, and which direction merging or splitting zones shifts the deviation from intent.
