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-labelandpolicy-rules. - IM25 — The mandatory label is the only mechanism that closes the label axis; it
closes 1 fact and leaves open the
access-labelfact. - 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.
- 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.
"""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-labelfact 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-rulesdoes 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.