Skip to content
academia.sh

Lesson 04 / 09

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.

Contents

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.

# 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.

# 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 };
# 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.
"""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.
  • IM29The 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.
"""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.

# 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close