You’d think the hardest part of SELinux is writing .te rules. For a single-SoC product, it is. But once you’re supporting AML, RTK, and MTK simultaneously — each with its own BSP, its own HAL, its own sysfs nodes — the hard part shifts from “what to write” to “which directory does this rule belong in.”
This post covers pitfalls encountered in multi-SoC sepolicy work: silently ineffective build flags, companion CLs missed during cross-repo review, and type visibility failures caused by Treble isolation boundaries.
Fundamentals: The Three-Layer Protection Model
Before getting into architecture, let’s align on the basics. For SELinux to protect a node (file, socket, property), three layers are required — missing any one breaks the chain:
| Layer | What it does | Where | What happens if missing |
|---|---|---|---|
| Type declaration | Define a new security type | type sysfs_hdmi, file_type, sysfs_type; | Build fail: unknown type |
| Label binding | Bind a path to the type | file_contexts / property_contexts | Node stays at default label; rules don’t take effect |
| Allow rule | Permit a domain to operate on the type | allow in .te | avc: denied |
# 1. Type declaration
type sysfs_hdmi, file_type, sysfs_type;
# 2. Label binding (file_contexts)
/sys/class/hdmi/hdmi0/hpd_state u:object_r:sysfs_hdmi:s0
# 3. Allow rule (.te)
allow system_server sysfs_hdmi:file { open read };
The consequences of missing a layer are asymmetric: a missing type declaration fails the build, but missing the other two layers results in silent runtime failure. The latter is harder to detect and more expensive to debug. Keep this “three-layer completeness” in mind — every design decision below revolves around it.
Architecture: Layered Contract Design
Splitting Directories Isn’t Architecture
The usual first instinct is to create parallel directories per SoC:
sepolicy/
├── soc/aml/
├── soc/rtk/
└── soc/mtk/
Directories created — then what? The critical questions remain unanswered:
sysfs_hdmiis declared insoc/aml/, butsoc/rtk/also needs the same type. Who owns the declaration?- Multiple SoCs all have HDMI but with different sysfs paths. Do shared allow rules get duplicated for each?
ifeq ($(DEVICE_SOC_AML), true)— if nobody sets that flag, what happens to the entire directory?
The split should follow visibility contracts, not SoC names:
sepolicy/
├── common/ # Tier 1: shared across SoCs
│ ├── public/ # types exported for all vendor .te to reference
│ ├── *.te # shared domain allow rules
│ └── file_contexts # shared node labels
│
├── soc/ # Tier 2: SoC-specific
│ ├── aml/
│ ├── mtk/
│ └── rtk/
│
└── product/ # Tier 3: product/memory variant overrides
└── (PRODUCT_PRIVATE_SEPOLICY_DIRS)
Push Types Up, Push Allow Rules Down
| Where | Why |
|---|---|
Types that any SoC might reference → common/public/ | Vendor .te can only see public types (Treble isolation); this is the only way to make them cross-SoC referenceable |
SoC-specific domains + allow rules → soc/<soc>/ | Isolation — don’t pollute other SoCs |
Shared domain allow rules → common/ | Don’t let each SoC duplicate them |
There’s a hard Treble constraint here: vendor partition .te files can only reference types marked as public. If a type declaration lives in product-private, vendor-side .te files referencing it will fail at build time — not runtime, it simply won’t compile. So any type that will be referenced across SoCs goes into common/public/, and the visibility problem ceases to exist.
Pitfalls in Practice
Silently Ineffective Build Flags
The most lethal issue isn’t avc: denied — it’s an entire SoC’s sepolicy directory vanishing without a trace.
# ❌ Dangerous: multiple independent boolean flags
ifeq ($(DEVICE_SOC_AML), true)
BOARD_SEPOLICY_DIRS += vendor/<platform>/sepolicy/soc/aml
endif
If no makefile ever sets DEVICE_SOC_AML, the ifeq is always false. The build won’t complain — it just silently skips the entire directory. Not a single .te rule gets loaded, the build passes, and the problem defers to runtime as a flood of avc: denied messages. You’ll assume the rules are wrong when in fact they were never loaded.
Use a single enum variable with path concatenation instead:
# device/<vendor>/<soc>/soc.mk
TARGET_SOC_FAMILY := aml # every SoC's device makefile must set this
# shared sepolicy.mk
BOARD_SEPOLICY_DIRS += vendor/<platform>/sepolicy/common
BOARD_SEPOLICY_DIRS += vendor/<platform>/sepolicy/soc/$(TARGET_SOC_FAMILY)
$(TARGET_SOC_FAMILY) concatenated directly into the path is safer than ifeq: if the variable is unset, the path becomes soc/, and the build fails early because the directory doesn’t exist — instead of deferring to runtime. Add an assertion to surface the failure even earlier:
ifeq ($(TARGET_SOC_FAMILY),)
$(error TARGET_SOC_FAMILY not set - check device soc.mk)
endif
The underlying philosophy is the same as handling memory variants: variants are configuration, not forks. One set of makefiles, one sepolicy tree, driven down different paths by variables — not multiple forked directories maintained independently.
Cross-Repo Three-Layer Completeness
Someone submits a CL deleting an SELinux rule, the reviewer sees the CL is internally consistent and +2’s it. But the type’s file_contexts binding lives in a different repo and also needs a companion CL for removal — nobody catches it, build breaks after merge.
This isn’t a reviewer failing at their job. It’s a gap in the process. Expecting everyone to remember “when you see SELinux rule deletion, go check the other repo” shouldn’t be a process dependency.
The design goal is to keep the three layers converged:
# ✅ SoC-specific node, all three layers in soc/aml/
soc/aml/
├── sysfs_hdmi.te # type declaration + allow
└── file_contexts # label binding
# ❌ Three layers scattered across locations
common/public/sysfs_hdmi.te # type declaration
soc/aml/file_contexts # label binding
soc/aml/system_server.te # allow
Splitting three layers across repos/tiers is technically legal — the build system merges all BOARD_SEPOLICY_DIRS during compilation. But legal doesn’t mean maintainable. The goal is for a reviewer to look at one CL and be able to judge whether the three layers are complete. So SoC-specific nodes get all three layers in the corresponding soc/<soc>/; only truly cross-SoC shared types get promoted to common/public/.
init_daemon_domain() Missing file_contexts
When init_daemon_domain(my_service) declares an init-launched service domain, SELinux needs to know which binary maps to that domain — and that information comes from file_contexts.
# .te
init_daemon_domain(my_service)
# file_contexts (without this, domain transition won't happen)
/system/bin/my_service_binary u:object_r:my_service_exec:s0
What if you write init_daemon_domain() but forget to label the binary in file_contexts? The binary runs under init’s domain — with all of init’s permissions. This is a real privilege escalation issue, and the build won’t say a word.
This shouldn’t rely on human review. AOSP ships sepolicy_tests and checkfc, which can detect file_contexts referencing nonexistent types, unlabeled binaries, and similar problems. Wire them into CI as a gating check.
get_prop() Redundancy
Manually expanding permissions that a macro already includes:
# ❌ Redundant: get_prop() already includes open/read/getattr
allow my_domain my_prop:file { open read getattr };
get_prop(my_domain, my_prop)
# ✅ The macro handles it
get_prop(my_domain, my_prop)
get_prop() expands to allow ... file { open read getattr map }. Writing it again manually won’t break anything, but policy bloats, and it misleads reviewers into thinking there’s a special requirement — when there isn’t. This kind of redundancy is best caught by lint.
Extension: Tier 3 for Product Variants
If the platform has memory variants (2GB / 4GB STB), there will occasionally be sepolicy differences — for instance, a memory optimization service that only exists on 2GB devices.
PRODUCT_PRIVATE_SEPOLICY_DIRS += \
vendor/<platform>/sepolicy/product/$(MEMORY_VARIANT)
Align this with soong_config and manifest overlay mechanisms. The “variants as configuration” philosophy should run consistently from manifests and build flags all the way through to sepolicy.
Summary
| Decision | Principle | Anti-pattern |
|---|---|---|
| Type visibility | Cross-SoC types → common/public/ | Public types in product-private; vendor .te won’t compile |
| SoC isolation | SoC-specific three layers converge in soc/<soc>/ | Three layers scattered across repos/tiers; companion CL missed |
| Build flags | Single enum variable + path concatenation + assert | Multiple boolean flags; silent no-op when setter is missing |
| Completeness checks | checkfc / sepolicy_tests in CI | Relying on human reviewers |
| Product variants | Tier 3 via PRODUCT_PRIVATE_SEPOLICY_DIRS | Forking sepolicy directories and maintaining them independently |
| Macros | Trust the expansion; don’t duplicate manually | Adding allow ... open read next to get_prop() |
Multi-SoC sepolicy ultimately becomes a governance problem: who defines the contract, who verifies it. Directory structure is just the contract’s shell. The three things that need to be nailed down are whether type declaration visibility boundaries are clearly defined, whether three-layer completeness can be machine-verified, and whether build flag failures get caught early. Get those right, and the rest is execution.
Note:
TARGET_SOC_FAMILYis an illustrative variable name — AOSP has no such standard variable. When implementing, use whatever SoC identification variable your platform’s conventions call for.