---
title: 'Mandatory Access Control'
source: 'https://academia.sh/en/courses/kernel-interfaces/mandatory-access-control'
course: 'Kernel Interfaces and Isolation'
language: en
updated: '2026-08-17T18:09:58+00:00'
license: 'CC BY-SA 4.0'
---

# Mandatory Access Control

Policy-based control closes one of the label axis's two facts and leaves the access label open; of 36 access pairs, 8 are required, while a coarse policy leaves 10 excess permissions and a narrow policy leaves 1.

The previous lesson narrowed which privileged operation a process can request from the
kernel, and counted something left open on the file side. Beneath what stays open lies a
more general rule: who can access a file is decided by that file's **owner**. If the owner
marks a directory world-writable, a process with dropped capabilities writes there too.
Dropping filters the request, it does not write the permission itself.

This lesson takes up the layer that takes the decision out of the owner's hands and binds
it to a policy, and measures two separate surfaces: the fact the policy closes, and the
**policy's own exposed surface**, that is, the number of accesses allowed when not needed.
The lesson teaches **writing** policy; disabling the policy is not offered as a solution
anywhere in this course, and its measure is counted at the end too.

## From Discretionary Control to Mandatory Control

File permissions and ownership are a **discretionary** model: the object's owner has the
authority to widen access, and does not ask anyone when widening it. The model is common
because it is simple, and that is also where its weakness lies — a single wrong marking
opens an entire directory, and there is no written place across the system showing which
subject can access which object.

**Mandatory access control** carries the decision to a central policy. In the kernel,
there is a layer of hooks placed at the points access checks pass through; a security
module attaches to these hooks and makes a second decision on every access request. The
rule is: the classic permission check runs **first**, and if it passes, the policy check
runs. The two are chained in series, and **an access that does not pass both does not
happen.** The policy cannot widen the permission the owner grants; it can only narrow it.

The policy has two objects. The **access label** is a mark attached to the subject and the
object; a process carries a label, a file carries a label. A **policy rule** forms a bond
between two labels and an operation class. The default behavior is to **deny**: every
access not explicitly allowed by a rule is denied. This is the exact opposite of the
discretionary model, and it makes writing the policy mandatory — without a rule, the
program does not run.

```text
# example dump — not executed; label fields are fiction
$ ls -Z /data/metrics
system_u:object_r:metrics_data_t:s0  raw.dat
system_u:object_r:metrics_log_t:s0  log.jsonl
$ ps -Z -p 8140
system_u:system_r:metrics_t:s0  8140 metrics-collector
```

The label has fields, and the decision mostly rests on just one of them, the type field.
The process is of type `metrics_t`, the metrics data is `metrics_data_t`, the log file is
`metrics_log_t`. The policy counts the allowed accesses among these types.

## Two Families and How a Rule Is Written

Security modules fall into two families, and the split is about how the object is named.
The **label-based** family keeps the access label on the object itself: the label is in
the file's metadata, and it travels with the file even if the file is moved or renamed.
The **path-based** family, by contrast, names the object by its file path and writes rules
over path patterns. The first is resilient to moves and turns labeling into a separate
job; the second's syntax is shorter, and the rule's scope can change when the same file is
accessed by another path.

```text
# example policy fragment — not executed
allow metrics_t metrics_data_t : file { read write getattr };
allow metrics_t metrics_log_t : file { append getattr };
allow metrics_t metrics_config_t : file { read getattr };
```

```text
# example path-based profile fragment — not executed
/usr/local/bin/metrics-collector {
  /data/metrics/** rw,
  /data/metrics/log.jsonl a,
  /etc/metrics/config.toml r,
  deny /data/billing/** rwx,
}
```

Both forms of syntax say the same three things: subject, object, and operation class. The
dumps have not been executed, and no numeric claim comes from them. Rule syntax varies by
security module; what this lesson measures is not syntax, it is **how many accesses the
rules allow**.

In the label-based family, there is a second written piece alongside the rules, and the
policy silently falls apart when it is forgotten: **transition rules**. A newly created
file by default takes the type of the directory it is in; if the desired type is something
else, this is written with a **type transition** rule. When it is not written, the log
file the metrics process produces is born with the data type, the log rules do not apply
to it, and no one notices a thing. The same gap opens for files restored from backup or
copied from another machine: the label is metadata, and not every transfer method carries
it. This is why part of writing a policy is also the **relabeling** step — the root tree or
the data directory is marked from scratch according to the written labeling rules. The
path-based family has no such step; in exchange, it has the problem of falling outside a
rule's scope when the same file is accessed by a second path.

## Label Axis: Two Facts, One Closure

- **IM24** — The label axis has **2 facts**: `access-label` and `policy-rules`.
- **IM25** — The mandatory label is the only mechanism that closes the label axis; it
  closes **1 fact** and **leaves open** the `access-label` fact.
- **IM26** — The two module families are represented in the model by a single mechanism.
- **IM27** — Bundle order is binding: five namespaces, the cgroup, capability dropping,
  the mandatory label. When the eighth mechanism is added, the bundle takes the name
  **strict bundle**.

```python
"""Shared definition, the part this lesson uses: 24 facts, 9 axes, and
the mandatory label that closes the label axis. What is measured: REMAINING EXPOSED SURFACE."""

FACTS = [
    ("other-processes-list", "process"), ("own-process-number", "process"),
    ("ancestor-process-chain", "process"),
    ("root-filesystem-tree", "mount"), ("other-mounts", "mount"),
    ("shared-temp-dir", "mount"),
    ("machine-interfaces", "network"), ("machine-routing-table", "network"),
    ("listening-ports", "network"),
    ("user-id-mapping", "user"), ("file-ownership", "user"),
    ("machine-name", "machine"),
    ("cpu-share", "resource"), ("memory-limit", "resource"),
    ("used-memory", "resource"), ("cpu-count", "resource"),
    ("kernel-version", "kernel"), ("kernel-settings", "kernel"),
    ("system-load", "kernel"), ("clock", "kernel"),
    ("capability-set", "privilege"), ("file-permissions", "privilege"),
    ("access-label", "label"), ("policy-rules", "label"),
]
MECHANISMS = {
    "pid-namespace":     {"axis": "process", "left_open": ["own-process-number"]},
    "mount-namespace":   {"axis": "mount", "left_open": ["root-filesystem-tree"]},
    "network-namespace": {"axis": "network", "left_open": []},
    "user-namespace":    {"axis": "user", "left_open": ["file-ownership"]},
    "uts-namespace":     {"axis": "machine", "left_open": []},
    "cgroup":            {"axis": "resource", "left_open": ["cpu-count"]},
    "capability-dropping": {"axis": "privilege", "left_open": ["file-permissions"]},
    "mandatory-label":   {"axis": "label", "left_open": ["access-label"]},
}
SEVEN = ["pid-namespace", "mount-namespace", "network-namespace",
         "user-namespace", "uts-namespace", "cgroup", "capability-dropping"]
STRICT = SEVEN + ["mandatory-label"]


def visible(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    closed = set()
    for m in mechanisms:
        v = MECHANISMS[m]
        closed |= {o for o, e in facts if e == v["axis"]}
        closed -= set(v["left_open"])
    return [o for o, _ in facts if o not in closed]


def open_axes(mechanisms, facts=None):
    facts = FACTS if facts is None else facts
    g = set(visible(mechanisms, facts))
    return sorted({e for o, e in facts if o in g})


open_facts = set(visible(["mandatory-label"]))
print("ORACLE  fact:", len(FACTS), "| axis:", len({e for _, e in FACTS}))
print("label axis has", sum(1 for _, e in FACTS if e == "label"), "facts")
print()
for o, e in FACTS:
    if e == "label":
        print(f"  {o:20s} {'LEAVES OPEN' if o in open_facts else 'closes'}")
g = visible(["mandatory-label"])
print("mandatory label alone: closed", len(FACTS) - len(g),
      "| visible", len(g), "| open axis", len(open_axes(["mandatory-label"])))
print()
print("stage                      mechanism  visible  open axis")
for name, d in (("seven mechanisms", SEVEN), ("+mandatory label (strict)", STRICT)):
    print(f"  {name:24s} {len(d):7d}  {len(visible(d)):7d}  {len(open_axes(d)):10d}")
print()
ADDED = FACTS + [("policy-version", "label")]
MISSING = [(o, e) for o, e in FACTS if o != "access-label"]
print("fact set sweep (strict bundle applied)")
print("set                   fact  axis  visible  open axis")
for name, facts in (("base", FACTS), ("+policy-version", ADDED),
                     ("-access-label", MISSING)):
    print(f"  {name:19s} {len(facts):4d}  {len({x for _, x in facts}):5d}"
          f"  {len(visible(STRICT, facts)):7d}  {len(open_axes(STRICT, facts)):10d}")
```

```
ORACLE  fact: 24 | axis: 9
label axis has 2 facts

  access-label         LEAVES OPEN
  policy-rules         closes
mandatory label alone: closed 1 | visible 23 | open axis 9

stage                      mechanism  visible  open axis
  seven mechanisms               7       11           7
  +mandatory label (strict)       8       10           7

fact set sweep (strict bundle applied)
set                   fact  axis  visible  open axis
  base                  24      9       10           7
  +policy-version       25      9       10           7
  -access-label         23      9        9           6
```

Three numbers stand side by side. **Oracle:** 24 facts, 9 axes. **Isolation:** the
mandatory label closes **1 fact**, and the strict bundle closes **14 facts** in total.
**Remaining exposed surface:** **10 facts** after the eighth mechanism, and **7 axes**
again. Eight mechanisms have been added, and the open axis count has not changed since the
fourth.

The fact left open is `access-label`, and the reason is structural: the policy attaches a
label to the subject, and the subject can read its own label. Hiding the label would make
the policy unworkable, because the label is the decision's input. The narrowing path is
therefore not hiding the label, it is making sure **the label carries no hidden
information at all**: type names announce role and purpose, not identity, key, tenant
name, or location. The second step is limiting the policy source itself — the rule tree is
not placed in the process's root tree; the only thing the process can read is its own
label, not the whole set of rules. The sweep confirms this: when the label axis's single
fact left open is removed, the axis closes, the surface drops to **9**, and the open axis
count to **6**.

## The Policy's Own Exposed Surface

When a policy is written, a second measurement is born. Throughout the course we counted
the exposed surface over kernel facts; the same question can be asked at the policy level
too: **how many accesses were allowed when not needed.** The fiction is three subjects,
four objects, and three actions on the metrics server.

- **IM28** — Three subjects, four objects, and three actions produce **36 access pairs**.
- **IM29** — **The oracle is the accesses the workload genuinely needs**; the side that
  built the fiction knows this set, and it consists of **8 accesses**.
- **IM30** — The policy's exposed surface is its **excess permission** count: accesses
  allowed but not needed. **Missing permission**, by contrast, shows broken work.

```python
"""Policy-level exposed surface: number of accesses allowed but NOT NEEDED."""
SUBJECTS = ("metrics", "billing", "maintenance")
OBJECTS = ("metrics-data", "billing-data", "log", "config")
ACTIONS = ("read", "write", "execute")

# Oracle: the accesses the workload GENUINELY needs.
REQUIRED = {
    ("metrics", "metrics-data", "read"), ("metrics", "metrics-data", "write"),
    ("metrics", "log", "write"), ("metrics", "config", "read"),
    ("billing", "billing-data", "read"), ("billing", "billing-data", "write"),
    ("billing", "log", "write"), ("maintenance", "log", "read"),
}


def no_policy(s, o, a):
    """No mandatory control: the decision is only in the discretionary layer."""
    return True


def coarse(s, o, a):
    """A broad rule written at the type level."""
    return o.startswith(s) or o == "log" or (o == "config" and a == "read")


def narrow(s, o, a):
    """The required accesses, plus config read for maintenance."""
    return (s, o, a) in REQUIRED or (s, o, a) == ("maintenance", "config", "read")


ALL = [(s, o, a) for s in SUBJECTS for o in OBJECTS for a in ACTIONS]
print("total access pairs:", len(ALL), "| oracle (required access):", len(REQUIRED))
print()
print("policy            allowed  satisfied  EXCESS PERMISSION  missing permission")
for name, f in (("no policy", no_policy), ("coarse policy", coarse),
                ("narrow policy", narrow)):
    allowed = {t for t in ALL if f(*t)}
    print(f"  {name:15s} {len(allowed):5d}  {len(REQUIRED & allowed):10d}"
          f"  {len(allowed - REQUIRED):10d}  {len(REQUIRED - allowed):10d}")
```

```
total access pairs: 36 | oracle (required access): 8

policy            allowed  satisfied  EXCESS PERMISSION  missing permission
  no policy          36           8          28           0
  coarse policy      18           8          10           0
  narrow policy       9           8           1           0
```

Three rows carry the entire tension of writing a policy. With no policy, all 36 accesses
pass through the policy layer, and **28 of them are unnecessary**. A coarse policy written
at the type level brings this down to **10**; missing permission is zero on all three
rows, meaning the work runs in all three. A narrow policy brings excess permission down to
**1**, and the work still runs. The difference between them is the writing effort: the
coarse policy is written with three rule lines, the narrow policy with nine.

**The measure is not how many rules a policy contains, but how many unnecessary accesses
it leaves standing.** This is the policy-level counterpart of the course's rule, and it is
said in the same sentence: a policy whose remaining permissions are not written down
counts as unmeasured.

## How a Policy Is Written

The required access list is not found by guessing. The writing order works in reverse: in
a test environment the policy is put into **permissive mode** — rules are evaluated and
violations are logged, but access is not blocked — the workload is run from start to
finish, and a single rule is written for every record that lands in the log.

```text
# example denial record — not executed
type=access-denied action={ write } process=8140 object="raw.dat" class=file
  subject=system_u:system_r:metrics_t:s0  target=system_u:object_r:billing_data_t:s0
```

The three fields the record carries are the rule's three fields: subject type, target
type, operation class. When writing the rule, turning the record into a rule as-is is easy
but wrong; the record points to a single file, the rule covers a type. The question to ask
before writing is: is this access genuinely needed, or is it a mislabeled file. In the
second case, the correct fix is not adding a rule, it is moving the file to the correct
type.

Permissive mode has a cost, and it can be stated with this lesson's numbers: in that mode
the `policy-rules` fact does not close, the strict bundle behaves like seven mechanisms
instead of eight, and the exposed surface is **11 facts instead of 10**. Permissive mode is
a development step, not an operating posture; permissive mode left on in production
corresponds, in the measurement, to losing one mechanism. Disabling the policy entirely is
not offered as a solution in this course — the first row of the table above gives, in
numbers, what disabling it means: **28 excess permissions**. If a rule breaks a workflow,
the correct response is not removing the policy, it is writing a narrow rule for the
single broken access or fixing the object's type.

Every number in the model is a full count over the fiction; it is not a measurement taken
on a real machine, and the number of access pairs in a real policy is far larger.

## Summary

- Mandatory access control takes the decision away from the object's owner and carries it
  to a central policy; it works **in series** with the classic permission check, and its
  default behavior is to **deny**.
- The policy has two objects: the **access label** attached to the subject and the object,
  and the **policy rule** that binds two labels to an operation class. The two module
  families name the object by label or by path.
- The label axis has **2 facts**; the mandatory label closes **1** of them, leaving the
  `access-label` fact open. The strict bundle closes **14 facts** with eight mechanisms;
  **10 facts** and **7 axes** stay open.
- There is an exposed surface at the policy level too: of 36 access pairs, **8** are
  required, while excess permission is **28** with no policy, **10** with a coarse policy,
  and **1** with a narrow policy; missing permission is zero in all three.
- A policy is written from denials collected in permissive mode; in permissive mode
  `policy-rules` does not close, and the exposed surface is **11 facts instead of 10**.
  Disabling the policy's counterpart in the measurement is **28 excess permissions**.

## Next Step

Four lessons measured four mechanism families separately: namespaces narrowed visibility,
the cgroup narrowed consumption, capability dropping narrowed privilege, the mandatory
label narrowed access. On a real system these are not turned on one by one; a runtime
reads them all from a single definition and applies them at the same time. The next lesson
measures that combination: it places a commonly used six-mechanism bundle next to a strict
eight-mechanism bundle side by side, counts how many facts adding two mechanisms narrows
the surface by, and shows why the open axis count stays at seven. The number to be seen
there is smaller than expected.
