<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: onurbaydas</title>
    <description>The latest articles on DEV Community by onurbaydas (@onurbaydas).</description>
    <link>https://dev.to/onurbaydas</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4179642%2F00ef6a88-778f-4551-8832-67d1d604bde2.png</url>
      <title>DEV Community: onurbaydas</title>
      <link>https://dev.to/onurbaydas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/onurbaydas"/>
    <language>en</language>
    <item>
      <title>I'm building a small web UI for KVM/libvirt as a hobby project</title>
      <dc:creator>onurbaydas</dc:creator>
      <pubDate>Thu, 22 Oct 2026 17:28:12 +0000</pubDate>
      <link>https://dev.to/onurbaydas/im-building-a-small-web-ui-for-kvmlibvirt-as-a-hobby-project-4g70</link>
      <guid>https://dev.to/onurbaydas/im-building-a-small-web-ui-for-kvmlibvirt-as-a-hobby-project-4g70</guid>
      <description>&lt;p&gt;Hi everyone 👋&lt;/p&gt;

&lt;p&gt;I want to share a hobby project I've been working on: &lt;strong&gt;VirtoMir&lt;/strong&gt;, a web interface for managing virtual machines on a Linux host running KVM/QEMU with libvirt.&lt;/p&gt;

&lt;p&gt;It's early, it's rough in places, and I'm mostly posting to get feedback from people who know this space better than I do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I started it
&lt;/h2&gt;

&lt;p&gt;I have a single KVM box at home. The existing options didn't quite fit how I wanted to use it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;virsh&lt;/strong&gt; is great, but I wanted something visual for day-to-day work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;virt-manager&lt;/strong&gt; is detailed, but it's a desktop app and awkward to use remotely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxmox&lt;/strong&gt; is excellent, but it's a whole distribution. For one machine that felt like more than I needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cockpit&lt;/strong&gt; is close, but I kept dropping to XML for the settings I cared about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I started building a browser UI that sits &lt;strong&gt;on top of an existing libvirt install&lt;/strong&gt;. It doesn't replace anything: your VMs stay normal libvirt VMs, and anything you do in &lt;code&gt;virsh&lt;/code&gt; shows up in the UI too. If you remove VirtoMir, your machines are untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does today
&lt;/h2&gt;

&lt;p&gt;The basics I use every day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a VM from an ISO or a cloud image (with cloud-init)&lt;/li&gt;
&lt;li&gt;Start, stop, snapshot, clone&lt;/li&gt;
&lt;li&gt;A console in the browser (VNC / SPICE / serial)&lt;/li&gt;
&lt;li&gt;Live CPU, disk and network graphs, updated every second&lt;/li&gt;
&lt;li&gt;An XML diff before any settings change is saved, so you see exactly what will change&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2sw1jt6wsznlviwwzega.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2sw1jt6wsznlviwwzega.png" alt="Machine performance" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are more features in the repo (multi-host, backups and so on), but honestly many of them are only tested in my own lab, and some haven't been tried on real hardware at all. The README marks those as experimental.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Go, talking to libvirt. It ships as a single binary with the UI embedded, with no database or agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; Svelte 5 + TypeScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live data:&lt;/strong&gt; Server-Sent Events, one sample per second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign-in:&lt;/strong&gt; your Linux account through PAM, behind self-signed TLS by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  About AI
&lt;/h2&gt;

&lt;p&gt;I want to be upfront: I'm building this &lt;strong&gt;with heavy AI assistance&lt;/strong&gt;. I decide what to build, review the changes, and test them on my own KVM host, but a lot of the code was written together with an AI coding assistant.&lt;/p&gt;

&lt;p&gt;I know there's a lot of fatigue around AI-generated projects, and I understand why. That's one more reason I'd really value people looking at it critically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it (safely)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Online demo&lt;/strong&gt; with made-up data, nothing to install: &lt;a href="https://onurbaydas.github.io/VirtoMir/demo/" rel="noopener noreferrer"&gt;https://onurbaydas.github.io/VirtoMir/demo/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try it in a throwaway VM&lt;/strong&gt; without touching your own servers: &lt;a href="https://github.com/onurbaydas/VirtoMir/blob/main/docs/try-in-a-vm.md" rel="noopener noreferrer"&gt;https://github.com/onurbaydas/VirtoMir/blob/main/docs/try-in-a-vm.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Please don't put it on anything important yet. It's a 0.x hobby project.&lt;/p&gt;

&lt;h2&gt;
  
  
  I'd love your feedback
&lt;/h2&gt;

&lt;p&gt;I'm still learning, so any of these would help a lot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What feels confusing or missing compared to the tools you use now?&lt;/li&gt;
&lt;li&gt;Does it install and run on your distro? (I've mostly tested Ubuntu.)&lt;/li&gt;
&lt;li&gt;Anything that looks wrong or unsafe in the code?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/onurbaydas/VirtoMir" rel="noopener noreferrer"&gt;https://github.com/onurbaydas/VirtoMir&lt;/a&gt;. Issues, discussions and comments here are all welcome.&lt;/p&gt;

&lt;p&gt;Thanks for reading! 🙏&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>linux</category>
      <category>go</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Time-Boxed Logins on Shared Linux Workstations: PAM, SSSD, and a Session Watchdog</title>
      <dc:creator>onurbaydas</dc:creator>
      <pubDate>Sat, 17 Oct 2026 17:40:53 +0000</pubDate>
      <link>https://dev.to/onurbaydas/time-boxed-logins-on-shared-linux-workstations-pam-sssd-and-a-session-watchdog-3o3p</link>
      <guid>https://dev.to/onurbaydas/time-boxed-logins-on-shared-linux-workstations-pam-sssd-and-a-session-watchdog-3o3p</guid>
      <description>&lt;p&gt;&lt;em&gt;Bu yazının Türkçesi: &lt;a href="https://onurbaydas.com/posts/reservation-session-control/" rel="noopener noreferrer"&gt;Paylaşımlı Linux İş İstasyonlarında Süreli Giriş&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have a room of shared Linux workstations. They are joined to Active Directory, users sign in with their domain accounts, and the machines are too valuable to be first come, first served. People book a specific machine for a specific window: Tuesday 09:00 to Thursday 18:00 on &lt;code&gt;ws07&lt;/code&gt;, for example. The system has to enforce three rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Nobody signs in without a valid reservation for this machine, right now.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When the reservation ends, the session ends.&lt;/strong&gt; Not at the next logout, and not when someone notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An administrator can change things centrally.&lt;/strong&gt; Cancel, extend, put a machine into maintenance, ban a user, and see the effect within a minute or two.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first rule is about &lt;em&gt;admission&lt;/em&gt;. The second is about &lt;em&gt;eviction&lt;/em&gt;. The third is about &lt;em&gt;who owns the truth&lt;/em&gt;. Most tools you can find solve the first rule, and solve it well. Hardly any of them solve the second. For the third, you usually end up editing a config file on every host.&lt;/p&gt;

&lt;p&gt;This post covers the architecture I built for it (a PAM login gate, SSSD, a session watchdog, a heartbeat, and a central reservation API), the trade-offs I accepted, and the edge cases a lab rebuild exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Off-the-Shelf Options and Why They Fall Short
&lt;/h2&gt;

&lt;p&gt;Before writing any code, I compared what already exists on the seven properties this problem needs: dynamic reservations, machine-specific assignment, expiry in the middle of a session, advance warning, central revoke and extend, behavior during a network outage, and an audit trail.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Dynamic reservations&lt;/th&gt;
&lt;th&gt;Machine-specific&lt;/th&gt;
&lt;th&gt;Mid-session expiry&lt;/th&gt;
&lt;th&gt;Advance warning&lt;/th&gt;
&lt;th&gt;Central revoke/extend&lt;/th&gt;
&lt;th&gt;Outage behavior&lt;/th&gt;
&lt;th&gt;Audit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AD &lt;code&gt;logonHours&lt;/code&gt; + GPO, &lt;strong&gt;Windows clients&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;No: a weekly hour grid per account&lt;/td&gt;
&lt;td&gt;Logon-rights GPOs per OU (&lt;code&gt;userWorkstations&lt;/code&gt; is deprecated)&lt;/td&gt;
&lt;td&gt;Yes, with "Set action to take when logon hours expire" (lock, disconnect, log off)&lt;/td&gt;
&lt;td&gt;Yes, when an action is set&lt;/td&gt;
&lt;td&gt;Edit the account or GPO&lt;/td&gt;
&lt;td&gt;Cached logons&lt;/td&gt;
&lt;td&gt;DC and client event logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same AD attributes, &lt;strong&gt;Linux via SSSD&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;GPO logon rights via &lt;code&gt;ad_gpo_access_control&lt;/code&gt;, or &lt;code&gt;ad_access_filter&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No: evaluated at login&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Group or GPO change, seen at next login&lt;/td&gt;
&lt;td&gt;Cached credentials, no expiry by default&lt;/td&gt;
&lt;td&gt;syslog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pam_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No: static &lt;code&gt;time.conf&lt;/code&gt; per host&lt;/td&gt;
&lt;td&gt;Yes (local file)&lt;/td&gt;
&lt;td&gt;Not documented&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Push a file to every host&lt;/td&gt;
&lt;td&gt;Unaffected (local)&lt;/td&gt;
&lt;td&gt;syslog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pam_access&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No time dimension&lt;/td&gt;
&lt;td&gt;Yes (users × origins)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Push a file to every host&lt;/td&gt;
&lt;td&gt;Unaffected (local)&lt;/td&gt;
&lt;td&gt;syslog&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cron or systemd timer + kill&lt;/td&gt;
&lt;td&gt;Only by regenerating schedules&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, at fixed times&lt;/td&gt;
&lt;td&gt;Possible, but hand-built&lt;/td&gt;
&lt;td&gt;Push schedules&lt;/td&gt;
&lt;td&gt;Unaffected&lt;/td&gt;
&lt;td&gt;Whatever you log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slurm + &lt;code&gt;pam_slurm_adopt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes (jobs)&lt;/td&gt;
&lt;td&gt;Yes (nodes)&lt;/td&gt;
&lt;td&gt;Yes, for adopted processes when the job ends&lt;/td&gt;
&lt;td&gt;No desktop warning&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;scancel&lt;/code&gt;, time-limit updates&lt;/td&gt;
&lt;td&gt;Depends on &lt;code&gt;slurmctld&lt;/code&gt; and &lt;code&gt;slurmd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sacct&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PAM gate + watchdog + API (this post)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, within one poll interval&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, single source of truth&lt;/td&gt;
&lt;td&gt;Explicit grace period, then fail-closed&lt;/td&gt;
&lt;td&gt;Central audit log&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Group Policy on Windows is better than its reputation.&lt;/strong&gt; The documentation for the &lt;code&gt;LogonHoursPolicyDescription&lt;/code&gt; setting says it can lock the workstation, disconnect the user, or log them off when their logon hours expire. Users get a warning first, if an action is configured. The often-quoted "Force logoff when logon hours expire" setting, on the other hand, only affects SMB sessions. If your machines run Windows and your schedule is &lt;em&gt;weekly and static&lt;/em&gt;, GPO is enough. Don't write code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSSD on Linux only covers admission.&lt;/strong&gt; &lt;code&gt;sssd-ad&lt;/code&gt; can evaluate GPO logon rights (Interactive, RemoteInteractive, and the others) and map them to PAM services, and &lt;code&gt;ad_access_filter&lt;/code&gt; can restrict logins with an LDAP filter. The AD access provider also checks whether the account has expired. All of this runs in the PAM &lt;em&gt;account&lt;/em&gt; phase, which only runs at login. Neither the &lt;code&gt;sssd-ad&lt;/code&gt; nor the &lt;code&gt;sssd-ldap&lt;/code&gt; man page mentions &lt;code&gt;logonHours&lt;/code&gt; or &lt;code&gt;userWorkstations&lt;/code&gt;. And nothing in SSSD ends a session that is already running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;pam_time&lt;/code&gt; and &lt;code&gt;pam_access&lt;/code&gt; are local and static.&lt;/strong&gt; &lt;code&gt;pam_time&lt;/code&gt; provides only the account phase, and its man page describes granting or denying access, not ending sessions. Its rules live in &lt;code&gt;/etc/security/time.conf&lt;/code&gt; on each host: fine for "the lab closes at night", useless for "Alice has &lt;code&gt;ws07&lt;/code&gt; until Thursday".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slurm comes closest, but it is built for batch jobs and SSH.&lt;/strong&gt; &lt;code&gt;pam_slurm_adopt&lt;/code&gt; denies SSH to nodes where you have no running job, and adopts your SSH session into the job's cgroup, so cleanup happens when the job ends. However, its documentation says that &lt;code&gt;pam_systemd&lt;/code&gt; conflicts with it and must be disabled. Without it there is no logind session and no &lt;code&gt;/run/user/$UID&lt;/code&gt;, both of which a graphical desktop needs. For desktop users, Slurm is the wrong shape.&lt;/p&gt;

&lt;p&gt;Admission control is a solved problem. &lt;em&gt;Eviction on a dynamic, per-machine schedule, controlled from one place&lt;/em&gt; is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The design has five parts. Each part does one job and assumes the others can fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  The API Is the Single Source of Truth
&lt;/h3&gt;

&lt;p&gt;A central service holds the reservations, bans, maintenance windows, and active sessions. Every decision about "may this user be on this machine now?" is answered there, using the server's clock. The clients never compute a reservation window themselves. Cancel, extend, and ban happen in one place, a workstation with a drifting clock cannot extend its own reservation, and every admission and eviction becomes an audit row in one database.&lt;/p&gt;

&lt;p&gt;The clients use four endpoints: &lt;code&gt;check&lt;/code&gt; (may this user log in?), &lt;code&gt;validate&lt;/code&gt; (is this session still valid, and how many minutes are left?), &lt;code&gt;heartbeat&lt;/code&gt; (I am alive, and this user is here), and &lt;code&gt;end&lt;/code&gt; (this session is over).&lt;/p&gt;

&lt;h3&gt;
  
  
  The PAM Gate: Admission
&lt;/h3&gt;

&lt;p&gt;On each workstation, a small script runs in the PAM &lt;strong&gt;account&lt;/strong&gt; phase through &lt;code&gt;pam_exec&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/pam.d/common-account (Debian/Ubuntu), after pam_sss
account required pam_exec.so quiet /usr/bin/python3 /opt/reservation/pam_gate.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The auth phase answers "is this really you?", which is SSSD's job. The account phase answers "are you allowed in &lt;em&gt;here&lt;/em&gt;, &lt;em&gt;now&lt;/em&gt;?", and it runs even when auth is skipped, for example for an SSH public-key login. In the lab, every service that includes &lt;code&gt;common-account&lt;/code&gt; called the gate: &lt;code&gt;sshd&lt;/code&gt; (password and key), &lt;code&gt;login&lt;/code&gt;, &lt;code&gt;su&lt;/code&gt;, &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;cron&lt;/code&gt;, &lt;code&gt;atd&lt;/code&gt;, &lt;code&gt;gdm-password&lt;/code&gt; (which also unlocks the GNOME lock screen), and &lt;code&gt;systemd-user&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The script reads &lt;code&gt;PAM_USER&lt;/code&gt;, asks the API, and exits 0 or 1. A small whitelist of administrator accounts, and system accounts below UID 1000, skip the API entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the gate alone is not enough.&lt;/strong&gt; The account phase runs &lt;em&gt;once&lt;/em&gt;, at login. A user who logged in at 08:59 for a reservation ending at 09:00 could otherwise stay for a week.&lt;/p&gt;

&lt;h3&gt;
  
  
  SSSD: Identity, and the Offline Trap
&lt;/h3&gt;

&lt;p&gt;SSSD handles identity and authentication against the domain controller (in my lab, a Samba-based AD domain controller for &lt;code&gt;EXAMPLE.LOCAL&lt;/code&gt;). Two settings matter here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[domain/example.local]&lt;/span&gt;
&lt;span class="py"&gt;cache_credentials&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;True&lt;/span&gt;
&lt;span class="py"&gt;offline_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;15&lt;/span&gt;
&lt;span class="py"&gt;offline_timeout_max&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cache_credentials&lt;/code&gt; lets users sign in when the domain controller is unreachable. That is usually what you want on workstations. The trap is that the cached view of the account can be stale. In the lab, I disabled an account in AD after it had logged in once, then made the domain controller unreachable. SSSD went offline and accepted the cached password. Because the reservation was still valid, the PAM gate let the user in too. The &lt;code&gt;sssd.conf&lt;/code&gt; man page documents the default for &lt;code&gt;offline_credentials_expiration&lt;/code&gt; as &lt;code&gt;0 (No limit)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The opposite problem: while SSSD is offline, it only knows cached users, so an account created this morning fails before the PAM gate ever runs. Lowering &lt;code&gt;offline_timeout_max&lt;/code&gt; (default 3600 seconds) keeps the retry interval from growing to an hour, and a small timer that forces SSSD back online helps too.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Watchdog: Eviction
&lt;/h3&gt;

&lt;p&gt;A systemd service on each workstation runs this loop every 60 seconds:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram: see the original post: &lt;a href="https://onurbaydas.com/en/posts/reservation-session-control/" rel="noopener noreferrer"&gt;https://onurbaydas.com/en/posts/reservation-session-control/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The watchdog knows nothing about reservations; it acts on the API's answer. When we later added a maintenance mode, the clients needed no changes. To find users, it asks systemd-logind, which knows every session &lt;code&gt;pam_systemd&lt;/code&gt; registered (SSH, console, graphical). Eviction is &lt;code&gt;loginctl terminate-user&lt;/code&gt;, which is where the hardest edge cases turned out to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Heartbeat and No-Show
&lt;/h3&gt;

&lt;p&gt;The heartbeat tells the server the session is real (rows whose heartbeats stop get cleaned up), stamps the reservation's "first login" time, and &lt;em&gt;creates&lt;/em&gt; the session record if it is missing. That last part covers a user who got in during an API outage through the grace period and never went through &lt;code&gt;check&lt;/code&gt;; otherwise their reservation could be marked as a no-show while they sit at the machine. The first-login stamp is write-once, so a logout, reboot, or heartbeat timeout never turns a reservation back into a no-show.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Login Flow
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;Diagram: see the original post: &lt;a href="https://onurbaydas.com/en/posts/reservation-session-control/" rel="noopener noreferrer"&gt;https://onurbaydas.com/en/posts/reservation-session-control/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Decisions and Their Costs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fail-Open or Fail-Closed, and the Grace Period
&lt;/h3&gt;

&lt;p&gt;A pure fail-closed design evicts everyone whenever the server restarts, and a deploy that rebuilds a Python virtual environment and a web front end takes minutes. A pure fail-open design means unplugging a network cable gives you unlimited time. I chose a bounded middle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The PAM gate fails closed, with one exception.&lt;/strong&gt; If the API cannot be reached, the gate allows only the &lt;em&gt;last user who was successfully admitted on this machine&lt;/em&gt;, and only if that happened within the grace period. Everyone else is denied. This covers "my session crashed and I need to log back in during a deploy" without opening the door to strangers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The watchdog tolerates a bounded outage, then evicts.&lt;/strong&gt; In our deployment, the tolerance started at five minutes. We raised it to ten after a server deploy took longer than that. An outage that is entirely ours should not cost users their work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost: an outage longer than the grace period logs people out for something that is not their fault.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Goes in PAM and What Goes in the Watchdog
&lt;/h3&gt;

&lt;p&gt;I keep the PAM gate small and dumb: one HTTP request and a cache file. PAM runs inside &lt;code&gt;sshd&lt;/code&gt; and the display manager. A slow or crashing PAM module can lock &lt;em&gt;everyone&lt;/em&gt; out, including administrators if the whitelist logic breaks. So the gate has a strict timeout, a whitelist check &lt;em&gt;before&lt;/em&gt; any network call, and an exception handler that still lets whitelisted administrators in.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A note on interpreter overhead:&lt;/em&gt; Spawning &lt;code&gt;/usr/bin/python3&lt;/code&gt; via &lt;code&gt;pam_exec&lt;/code&gt; incurs roughly 30–80 ms of process startup and initialization overhead per invocation. On dedicated workstations with normal login frequencies, this cost is unnoticeable. However, in environments subject to frequent PAM evaluations (such as high-frequency automated cron jobs, sudo checks, or external SSH brute-force attempts), repeatedly spinning up Python processes can generate undesirable CPU spikes. A recommended production-grade optimization is to replace the Python script with a lightweight compiled binary (e.g., in Go, Rust, or C) or a thin client querying a persistent local daemon over a Unix domain socket.&lt;/p&gt;

&lt;p&gt;Everything that takes time or judgment lives in the watchdog: warnings, waiting, deciding to evict, and reporting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Warnings That Never Reach SSH Users (and the Wayland Trap)
&lt;/h3&gt;

&lt;p&gt;The watchdog warns users five minutes before the end with a desktop notification. Then, on one day, all three users whose reservations were ending turned out to be connected over SSH, where a desktop notification never arrives. We added server-side email reminders two hours and thirty minutes before the end, because the server is the only component that can reach someone who is not looking at the screen.&lt;/p&gt;

&lt;p&gt;Graphical notifications harbor an additional silent failure mode on modern Linux distributions: &lt;strong&gt;Wayland&lt;/strong&gt;. Running &lt;code&gt;notify-send&lt;/code&gt; or &lt;code&gt;zenity&lt;/code&gt; as root with &lt;code&gt;DISPLAY=:0&lt;/code&gt; works reliably under legacy X11, but fails completely without output under Wayland, where display sockets are strictly per-session. For a system daemon to reliably deliver graphical notifications on Wayland desktops, it must discover the target user's active session bus address (&lt;code&gt;DBUS_SESSION_BUS_ADDRESS&lt;/code&gt; extracted from &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt; of &lt;code&gt;gnome-session&lt;/code&gt; or &lt;code&gt;wayland-0&lt;/code&gt;) and invoke notification dispatchers inside that specific user's D-Bus context.&lt;/p&gt;

&lt;h3&gt;
  
  
  Maintenance Mode and Giving Time Back
&lt;/h3&gt;

&lt;p&gt;Maintenance is a server-side state, not a client feature. While it is active, &lt;code&gt;check&lt;/code&gt; denies everyone except administrators, &lt;code&gt;validate&lt;/code&gt; and &lt;code&gt;heartbeat&lt;/code&gt; report "invalid", the no-show sweep pauses, and automatic assignment stops. The heartbeat change was subtle but critical. Without it, the heartbeat recreated the session record that maintenance had just deleted, &lt;code&gt;validate&lt;/code&gt; then said "valid", and nobody was ever logged out.&lt;/p&gt;

&lt;p&gt;Users get their time back: each affected reservation is extended by its overlap with the maintenance window, up to the start of the next reservation on that machine. I deliberately did not cascade the extension; rescheduling five people to compensate one creates more problems than it solves. Time that cannot be returned is reported to an administrator. Maintenance also has a &lt;em&gt;mandatory&lt;/em&gt; planned end, so a forgotten window expires on its own.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Administrator Exemption
&lt;/h3&gt;

&lt;p&gt;Administrators bypass the gate and the watchdog. Both components check the whitelist before they talk to the network, so an administrator can always log in and repair a machine whose API connection is broken. The cost is that the whitelist is now part of every client's configuration, and a mistake in it is a privilege escalation. Keep it short and deploy it from one template.&lt;/p&gt;

&lt;h3&gt;
  
  
  Diagnosis Order
&lt;/h3&gt;

&lt;p&gt;"I can't log in" has at least nine different causes in this architecture: no reservation, the AD account doesn't exist, it is disabled, it is locked, the password has expired, SSSD is offline, the user doesn't resolve on the host, the password is wrong, or the gate denied the login. One ticket took two hours to diagnose by hand, so we automated the checks and fixed their &lt;strong&gt;order&lt;/strong&gt;: the cause furthest &lt;em&gt;upstream&lt;/em&gt; wins. A locked account also shows up as password failures in the logs; diagnose "wrong password" first and an administrator resets a password that cannot work while the lock is in place. The check only reads and never fixes anything, so people are not afraid to run it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases I Hardened
&lt;/h2&gt;

&lt;p&gt;To check the guarantee, I rebuilt the client side in a lab. The lab has a Samba-based AD domain controller, a reservation API that runs the &lt;em&gt;real&lt;/em&gt; server-side session and reservation code on SQLite, and an Ubuntu workstation container with systemd as PID 1. The SSSD and PAM settings come from the original configuration tasks, and the PAM gate and watchdog are copied unmodified. For each component I asked: &lt;em&gt;under normal operating conditions, when does "the session really ends, and no unauthorized session starts" stop being true?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Each case lists the status, what the lab showed, and the hardening. The lab reproduced the &lt;em&gt;current&lt;/em&gt; behavior; the hardening has not been measured in production yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;terminate-user&lt;/code&gt; Is Not "Everything the User Started"
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; observed in the lab, needs confirmation on bare-metal hosts. &lt;strong&gt;Severity:&lt;/strong&gt; high.&lt;/p&gt;

&lt;p&gt;When a reservation expired, the watchdog called &lt;code&gt;loginctl terminate-user&lt;/code&gt;, which stopped the user's systemd service manager and everything under it. Two kinds of processes kept running: background work left in a session that had &lt;em&gt;already logged out&lt;/em&gt; (logind showed it as "closing"), and jobs started by &lt;code&gt;cron&lt;/code&gt; or &lt;code&gt;at&lt;/code&gt;, which run in those services' cgroups, outside any login session, where neither logind nor the watchdog sees them. The effective &lt;code&gt;KillUserProcesses&lt;/code&gt; was &lt;code&gt;no&lt;/code&gt;, the Ubuntu default; per &lt;code&gt;logind.conf(5)&lt;/code&gt;, the session scope is then "abandoned" at logout and its processes keep running. As a knock-on effect, a user with leftovers stayed in &lt;code&gt;loginctl list-users&lt;/code&gt; as "closing", and the watchdog repeated its warn-and-wait routine for them on every cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The obvious instinct might be setting &lt;code&gt;KillUserProcesses=yes&lt;/code&gt; in &lt;code&gt;logind.conf&lt;/code&gt;. But on shared engineering workstations where users run multi-hour simulations or builds in &lt;code&gt;tmux&lt;/code&gt;, that blunt instrument causes collateral damage: a brief Wi-Fi drop or transient SSH disconnect would immediately abort an active workload, even with days remaining on the booking.&lt;/p&gt;

&lt;p&gt;Instead, the architecture decouples session disconnects from reservation expiry. Background workloads are permitted to persist across temporary disconnects while the reservation is active. Only when the reservation &lt;strong&gt;officially expires&lt;/strong&gt; does the watchdog step in to sweep the host clean:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify rather than assume:&lt;/strong&gt; After calling &lt;code&gt;terminate-user&lt;/code&gt;, the watchdog verifies whether the UID still owns any running processes (&lt;code&gt;pgrep -u &amp;lt;UID&amp;gt;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User slice teardown:&lt;/strong&gt; If processes linger, it tears down the user's entire slice directly (&lt;code&gt;systemctl stop user-&amp;lt;UID&amp;gt;.slice&lt;/code&gt;), forcibly eliminating abandoned session scopes stuck in the "closing" state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escalation and alarm:&lt;/strong&gt; If stubborn processes still survive, it issues &lt;code&gt;loginctl kill-user --signal=SIGKILL&lt;/code&gt; and notifies the central API to raise an alert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU Context and &lt;code&gt;/dev/shm&lt;/code&gt; Cleanup:&lt;/strong&gt; On high-performance GPU/CUDA workstations, terminating CPU processes alone may not be sufficient; crashed or zombie CUDA contexts and leftover shared memory segments under &lt;code&gt;/dev/shm&lt;/code&gt; can lock GPU memory and prevent the next user's CUDA allocations. Following the slice teardown, the cleanup routine sweeps &lt;code&gt;/dev/shm&lt;/code&gt; for user remnants and inspects GPU device nodes (&lt;code&gt;fuser -v /dev/nvidia*&lt;/code&gt; or &lt;code&gt;nvidia-smi&lt;/code&gt;) for memory leaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out-of-band execution control:&lt;/strong&gt; Restrict &lt;code&gt;cron&lt;/code&gt; and &lt;code&gt;at&lt;/code&gt; to administrators via &lt;code&gt;cron.allow&lt;/code&gt; and &lt;code&gt;at.allow&lt;/code&gt;, and have the watchdog audit the kernel process table (&lt;code&gt;ps -eo uid&lt;/code&gt;) directly so out-of-session jobs cannot escape oversight.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  2. The Client Cannot Tell Who It Is Talking To, and the Server Cannot Tell Which Machine Is Asking
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; verified (code and lab). &lt;strong&gt;Severity:&lt;/strong&gt; high.&lt;/p&gt;

&lt;p&gt;The client talked to the API over plain HTTP, and the session endpoints required no authentication. The gate trusted &lt;code&gt;"allowed": true&lt;/code&gt; from whoever answered at that address. Machine identity was just the hostname the client put in the request body. In the lab, &lt;code&gt;ws01&lt;/code&gt; asked about a reservation belonging to &lt;code&gt;ws02&lt;/code&gt;, got &lt;code&gt;allowed: true&lt;/code&gt;, and the server recorded a session for &lt;code&gt;ws02&lt;/code&gt;, although the request came from &lt;code&gt;ws01&lt;/code&gt;'s address. The &lt;code&gt;end&lt;/code&gt; endpoint can likewise close someone else's session record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening:&lt;/strong&gt; Use HTTPS with a pinned CA, and refuse an &lt;code&gt;http://&lt;/code&gt; URL in the configuration. For machine identity, use a secret every domain-joined workstation already has: the &lt;code&gt;host/&lt;/code&gt; principal in &lt;code&gt;/etc/krb5.keytab&lt;/code&gt;. The client authenticates to the API with Kerberos (SPNEGO), and the server takes the hostname from the authenticated principal, never from the request body. Per-machine mTLS certificates are the alternative if Kerberos to the API is not an option.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A "Ten-Minute" Grace Period That Lasted Under Five
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; verified. &lt;strong&gt;Severity:&lt;/strong&gt; medium.&lt;/p&gt;

&lt;p&gt;The watchdog counted &lt;em&gt;failed calls&lt;/em&gt;, not elapsed time, and it used one counter for the whole machine. With three users listed, every poll added three failures. The threshold was the number of minutes from the configuration. The API went unreachable at 19:11:24, and the first user was evicted at 19:16:10. Once the threshold was crossed, everyone else in the same cycle went with them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening:&lt;/strong&gt; measure time, not attempts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# last_api_ok is updated on every successful response (time.monotonic())
&lt;/span&gt;&lt;span class="n"&gt;outage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_api_ok&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;outage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;GRACE_SECONDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;terminate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API unreachable beyond grace period&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. A Serial Loop With &lt;code&gt;sleep&lt;/code&gt; Inside It
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; verified. &lt;strong&gt;Severity:&lt;/strong&gt; medium.&lt;/p&gt;

&lt;p&gt;When a session became invalid, the watchdog sent a notification, slept 30 seconds, and then terminated the session, one user at a time. In the lab, a single expiry took 47 seconds to enforce. Two users whose reservations ended at the same moment were evicted after 52 and 82 seconds. With N users, the last one gets about N × 30 extra seconds, and the "closing" user from case 1 added 30 seconds to &lt;em&gt;every&lt;/em&gt; cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening:&lt;/strong&gt; Use a per-user state machine. The first "invalid" answer sends the notification and records a deadline. A later cycle, or a timer, enforces it. There is no blocking sleep in the loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. A Stopped Watchdog Is a Silent Watchdog
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; verified. &lt;strong&gt;Severity:&lt;/strong&gt; medium.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Restart=always&lt;/code&gt; brought the service back 11 seconds after a &lt;code&gt;SIGKILL&lt;/code&gt;. A clean &lt;code&gt;systemctl stop&lt;/code&gt; (a package upgrade, a mistake, a debugging session) is different: the user stays indefinitely. In the lab, the user was still there three minutes after expiry and was evicted 31 seconds after the service came back. On the server, the stale session row eventually gets cleaned up, but the alerting only watched "is the machine up?", not "is the watchdog talking?".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening:&lt;/strong&gt; Send a machine-level heartbeat even when nobody is logged in, and alert when the machine is up but has been silent for X minutes. Use &lt;code&gt;Type=notify&lt;/code&gt; with &lt;code&gt;WatchdogSec=&lt;/code&gt; so a hung loop is restarted too, not only a dead process.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. A Disabled Account That Still Logs In
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; verified. &lt;strong&gt;Severity:&lt;/strong&gt; medium. This is the SSSD trap described earlier. It only matters when the domain controller is unreachable but the API is not, for example during DC maintenance or a partial network partition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;offline_credentials_expiration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;offline_failed_login_attempts&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also, make the account-disable workflow update the API, which is the single source of truth. Let &lt;code&gt;check&lt;/code&gt; consult the account's AD status, since the server already has LDAP access.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. A Ban That Waits for the Reservation to End
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; verified. &lt;strong&gt;Severity:&lt;/strong&gt; medium.&lt;/p&gt;

&lt;p&gt;Bans were checked in &lt;code&gt;check&lt;/code&gt; only. A user banned in the middle of a session was still logged in 2.5 minutes later. New logins were refused, but &lt;code&gt;validate&lt;/code&gt; and &lt;code&gt;heartbeat&lt;/code&gt; never looked at the ban table, and banning did not cancel the user's active reservations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening:&lt;/strong&gt; run the same ban lookup in &lt;code&gt;validate&lt;/code&gt;, or cancel active reservations when a ban is created. Then "revoke centrally" means within one poll interval.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. The Grace Cache Trusts the Local Clock
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; verified. &lt;strong&gt;Severity:&lt;/strong&gt; low to medium.&lt;/p&gt;

&lt;p&gt;The gate computed "minutes since the last successful admission" from the local clock. With the API down and the clock set back three hours, the log read &lt;code&gt;grace active (-180/10 min), allow&lt;/code&gt;. A negative age passes a &lt;code&gt;&amp;lt;= grace&lt;/code&gt; check. The cache also stored the reservation end time but never used it. On top of that, one configuration key controlled two different grace periods: the gate's and the watchdog's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cached_at&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PAM_GRACE_SECONDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grace cache invalid or expired&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached_reservation_end&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cached_reservation_end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reservation already ended&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give the two grace periods separate configuration keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. The PAM Line Was Not Where the Playbook Thought
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; verified. &lt;strong&gt;Severity:&lt;/strong&gt; low.&lt;/p&gt;

&lt;p&gt;The deployment inserted the &lt;code&gt;pam_exec&lt;/code&gt; line &lt;em&gt;before&lt;/em&gt; a line matching &lt;code&gt;account required pam_unix.so&lt;/code&gt;. On Ubuntu, that line begins with &lt;code&gt;[success=1 new_authtok_reqd=done default=ignore]&lt;/code&gt;, so the pattern never matched, and the line was appended at the end of the file instead, after &lt;code&gt;account sufficient pam_localuser.so&lt;/code&gt;. Domain users are unaffected, but local accounts never reach the gate: that matches the intent, by accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening:&lt;/strong&gt; Anchor the line explicitly, right after &lt;code&gt;pam_sss&lt;/code&gt;. Test every PAM service in CI with &lt;code&gt;pamtester &amp;lt;service&amp;gt; &amp;lt;user&amp;gt; acct_mgmt&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. Feedback That Says Nothing, and Logs That Say Too Much
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; verified. &lt;strong&gt;Severity:&lt;/strong&gt; low.&lt;/p&gt;

&lt;p&gt;When the gate denied someone, &lt;code&gt;pam_exec&lt;/code&gt; returned &lt;code&gt;PAM_SYSTEM_ERR&lt;/code&gt;, so &lt;code&gt;su&lt;/code&gt; printed "System error" and SSH just refused the connection. The actual reason ("no reservation for this machine") only appeared in a client-side log. The warning to SSH users was logged as "Desktop notification sent" even in a lab container that had no display at all, because nobody checked the exit code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardening:&lt;/strong&gt; Use &lt;code&gt;pam_exec&lt;/code&gt;'s &lt;code&gt;stdout&lt;/code&gt; option to pass the reason to the user. Send warnings to the user's active terminals (&lt;code&gt;wall&lt;/code&gt; or writing directly to active PTYs) as well. Note that for SSH sessions, displaying PAM account rejection messages on the client terminal depends on server configuration (&lt;code&gt;UsePAM yes&lt;/code&gt;) and client handling (especially during public-key authentication). For graphical desktop sessions under Wayland, invoke notifications through the user's &lt;code&gt;DBUS_SESSION_BUS_ADDRESS&lt;/code&gt; rather than assuming a legacy &lt;code&gt;DISPLAY=:0&lt;/code&gt;. Finally, inspect notification command exit codes and log actual delivery results rather than just the invocation attempt.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. Smaller Ones
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Corrupt configuration:&lt;/strong&gt; the loader swallowed the parse error and fell back to built-in defaults, including a default API address. In the lab, the result was fail-closed after a five-second timeout, with no message saying why. &lt;em&gt;Hardening:&lt;/em&gt; log the failure as critical, and refuse to start the watchdog with an unreadable configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "is SSSD online?" timer&lt;/strong&gt; read &lt;code&gt;sssctl domain-status&lt;/code&gt;, which needs the InfoPipe responder (&lt;code&gt;sssd-dbus&lt;/code&gt;, only a &lt;em&gt;Recommends&lt;/em&gt; of &lt;code&gt;sssd-tools&lt;/code&gt;). Without that package, the check always reads "not online" and restarts SSSD every two minutes. &lt;em&gt;Hardening:&lt;/em&gt; install the package explicitly, and treat a failing &lt;code&gt;sssctl&lt;/code&gt; as "unknown", not "offline".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time zones:&lt;/strong&gt; every decision uses the server's UTC clock, which is the right choice. One user-facing message printed the reservation end without converting the time zone. Format times for humans at the edge, never in the decision path.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;If your schedule is static and weekly and your clients run Windows, use Group Policy. It warns users and can log them off when their hours run out, and it costs you nothing to maintain. On Linux with SSSD, the stock tools give you strong &lt;strong&gt;admission&lt;/strong&gt; control, through GPO logon rights, access filters, &lt;code&gt;pam_time&lt;/code&gt;, and &lt;code&gt;pam_access&lt;/code&gt;. They give you no &lt;strong&gt;eviction&lt;/strong&gt;, and no central place to change a reservation that will be different tomorrow. Slurm handles eviction well, but only for batch workloads that can live without &lt;code&gt;pam_systemd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The architecture here fills that gap with three ideas. Keep the server as the only place where time and policy are decided. Keep the PAM gate small and fail-closed. Treat eviction as a loop that verifies its own work.&lt;/p&gt;

&lt;p&gt;The lab mostly found problems in the loop, not in the policy: the watchdog counted the wrong thing, slept inside its own loop, trusted logind's view of "who is here", and logged attempts as successes, while client and server never proved who they were to each other. None of this is exotic. It is what happens when success means "I ran the command" instead of "the state I wanted is now true".&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Learn, &lt;a href="https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-admx-winlogon" rel="noopener noreferrer"&gt;ADMX_WinLogon Policy CSP: LogonHoursPolicyDescription, LogonHoursNotificationPolicyDescription&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn, &lt;a href="https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/network-security-force-logoff-when-logon-hours-expire" rel="noopener noreferrer"&gt;Network security: Force logoff when logon hours expire&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn, &lt;a href="https://learn.microsoft.com/en-us/windows/win32/adschema/a-logonhours" rel="noopener noreferrer"&gt;Logon-Hours attribute&lt;/a&gt; and &lt;a href="https://learn.microsoft.com/en-us/windows/win32/adschema/a-userworkstations" rel="noopener noreferrer"&gt;User-Workstations attribute&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manpages.ubuntu.com/manpages/noble/man5/sssd-ad.5.html" rel="noopener noreferrer"&gt;sssd-ad(5)&lt;/a&gt;, &lt;a href="https://manpages.ubuntu.com/manpages/noble/man5/sssd.conf.5.html" rel="noopener noreferrer"&gt;sssd.conf(5)&lt;/a&gt;, &lt;a href="https://manpages.ubuntu.com/manpages/noble/man5/sssd-ldap.5.html" rel="noopener noreferrer"&gt;sssd-ldap(5)&lt;/a&gt; (Ubuntu 24.04)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://man7.org/linux/man-pages/man8/pam_exec.8.html" rel="noopener noreferrer"&gt;pam_exec(8)&lt;/a&gt;, &lt;a href="https://man7.org/linux/man-pages/man8/pam_time.8.html" rel="noopener noreferrer"&gt;pam_time(8)&lt;/a&gt;, &lt;a href="https://man7.org/linux/man-pages/man8/pam_access.8.html" rel="noopener noreferrer"&gt;pam_access(8)&lt;/a&gt;, &lt;a href="https://man7.org/linux/man-pages/man8/pam_systemd.8.html" rel="noopener noreferrer"&gt;pam_systemd(8)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://man7.org/linux/man-pages/man5/logind.conf.5.html" rel="noopener noreferrer"&gt;logind.conf(5)&lt;/a&gt;, &lt;a href="https://man7.org/linux/man-pages/man1/loginctl.1.html" rel="noopener noreferrer"&gt;loginctl(1)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SchedMD, &lt;a href="https://slurm.schedmd.com/pam_slurm_adopt.html" rel="noopener noreferrer"&gt;pam_slurm_adopt&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>One User, Two UIDs: NFSv4, Kerberos, and Docker on a Domain-Joined TrueNAS</title>
      <dc:creator>onurbaydas</dc:creator>
      <pubDate>Tue, 13 Oct 2026 07:36:53 +0000</pubDate>
      <link>https://dev.to/onurbaydas/one-user-two-uids-nfsv4-kerberos-and-docker-on-a-domain-joined-truenas-15mi</link>
      <guid>https://dev.to/onurbaydas/one-user-two-uids-nfsv4-kerberos-and-docker-on-a-domain-joined-truenas-15mi</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In our shared GPU lab, several things broke in the same few weeks. Files on a user's NFS home showed up as &lt;code&gt;nobody:nogroup&lt;/code&gt;. Once we tightened the share, a user could no longer write to their own home, even though &lt;code&gt;ls&lt;/code&gt; showed them as the owner. A freshly provisioned home dataset came out owned by &lt;code&gt;root:root&lt;/code&gt; with mode &lt;code&gt;755&lt;/code&gt; instead of belonging to the user with mode &lt;code&gt;700&lt;/code&gt;. A student's Jupyter container opened with the prompt &lt;code&gt;I have no name!&lt;/code&gt; and &lt;code&gt;sudo&lt;/code&gt; refused to work.&lt;/p&gt;

&lt;p&gt;Most of this came from one root cause. The same Active Directory user had one UID on the Linux clients and a different UID on the storage server. Neither side was misconfigured on its own terms. In the end, we computed the base the clients used and set the storage server's ID range to start at the same number. Two more lookups on the same path, one on the client and one on the server, also had to be fixed before access worked.&lt;/p&gt;

&lt;p&gt;This post follows one identity from login to an NFS file, shows where the mismatch hurts, and covers the fix, a checklist, and alternatives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;The environment has four parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain controller:&lt;/strong&gt; a Samba-based AD appliance for the &lt;code&gt;EXAMPLE.LOCAL&lt;/code&gt; realm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage:&lt;/strong&gt; TrueNAS SCALE at &lt;code&gt;nas.example.local&lt;/code&gt; (192.0.2.10), joined to the same domain. Every user gets a separate ZFS dataset, exported over NFSv4 with &lt;code&gt;sec=krb5&lt;/code&gt; only. Each dataset is owned by its user with mode &lt;code&gt;0700&lt;/code&gt;. The owner has full access and everyone else has none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clients:&lt;/strong&gt; Ubuntu workstations joined to the domain with SSSD. &lt;code&gt;pam_mount&lt;/code&gt; mounts the user's dataset at login, and &lt;code&gt;rpc.gssd&lt;/code&gt; handles the Kerberos side of NFS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker:&lt;/strong&gt; users run their work (Jupyter, training jobs) in containers. The user's NFS directory is bind-mounted into the container as &lt;code&gt;/workspace&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The isolation goal is simple: user A must not be able to read user B's dataset, even with Docker in the picture.&lt;/p&gt;

&lt;p&gt;The diagram shows which identity is in effect at each hop, before the fix. The UIDs are examples.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Diagram: see the &lt;a href="https://onurbaydas.online/posts/nfs-uid-mismatch-en/" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The client's number never reaches the server; it only selects the Kerberos ticket. The server uses the principal and its own number, and the owner comes back as a name. The two numbers never meet, which is why the mismatch could hide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Containers Must Not Run as Root Here
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;sec=sys&lt;/code&gt;, the server trusts the UID the client sends. With &lt;code&gt;sec=krb5&lt;/code&gt;, it trusts a Kerberos principal, and the client picks that principal from the UID of the calling process.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the client picks a Kerberos identity
&lt;/h3&gt;

&lt;p&gt;Per the &lt;code&gt;rpc.gssd&lt;/code&gt; man page, user credentials live in a file with "a well-known name constructed using the user's UID", typically in &lt;code&gt;/tmp&lt;/code&gt;. SSSD writes the ticket cache under exactly that kind of name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/sssd/sssd.conf (domain section, excerpt)
&lt;/span&gt;&lt;span class="py"&gt;ldap_id_mapping&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;True&lt;/span&gt;
&lt;span class="py"&gt;krb5_ccachedir&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/tmp&lt;/span&gt;
&lt;span class="py"&gt;krb5_ccname_template&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;FILE:%d/krb5cc_%U&lt;/span&gt;
&lt;span class="py"&gt;krb5_renewable_lifetime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;7d&lt;/span&gt;
&lt;span class="py"&gt;krb5_renew_interval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;60m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;%U&lt;/code&gt; is the login UID and &lt;code&gt;%d&lt;/code&gt; is &lt;code&gt;krb5_ccachedir&lt;/code&gt; (&lt;code&gt;sssd-krb5(5)&lt;/code&gt;). Renewal keeps a long Jupyter session from losing its ticket.&lt;/p&gt;

&lt;p&gt;A container shares the host kernel, so when a container process touches &lt;code&gt;/workspace&lt;/code&gt;, &lt;code&gt;rpc.gssd&lt;/code&gt; looks for a ticket cache under that process's UID. The container's UID decides which identity the NAS sees.&lt;/p&gt;

&lt;h3&gt;
  
  
  What root gets
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;rpc.gssd&lt;/code&gt; man page is explicit about root: "By default rpc.gssd uses the system's machine credentials for UID 0 accesses that require GSS authentication."&lt;/p&gt;

&lt;p&gt;So root in the container reaches the NAS as the machine account, which owns none of the &lt;code&gt;0700&lt;/code&gt; datasets. Root gets &lt;em&gt;less&lt;/em&gt; access to the user's files than the user. That is good for security, but any setup step that writes to &lt;code&gt;/workspace&lt;/code&gt; as root fails.&lt;/p&gt;

&lt;p&gt;It failed even earlier for us. The launcher started containers as root with &lt;code&gt;-w /workspace&lt;/code&gt;. After the share became Kerberos-only, the container no longer started at all: the runtime's &lt;code&gt;chdir&lt;/code&gt; into &lt;code&gt;/workspace&lt;/code&gt; ran as root and was denied. The fix was to start the container with &lt;code&gt;-w /&lt;/code&gt;, so root never touches the NFS path, and to change into &lt;code&gt;/workspace&lt;/code&gt; only in the exec calls that run as the user.&lt;/p&gt;

&lt;p&gt;Our launcher used to create Jupyter directories as root and then &lt;code&gt;chown -R&lt;/code&gt; them to the user. On a Kerberos mount that step cannot work. We replaced it with a &lt;code&gt;mkdir&lt;/code&gt; run as the user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create directories AS THE USER (NFS, owner is the user)&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UID&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$GID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'
  mkdir -p /workspace/.jupyter/runtime /workspace/.local/bin /workspace/.cache
'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the host, a playbook running &lt;code&gt;chmod 0755&lt;/code&gt; on a mounted NFS path failed with &lt;code&gt;PermissionError: [Errno 1] Operation not permitted&lt;/code&gt;. Permissions belong to the server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running as the user, with root kept inside the container
&lt;/h3&gt;

&lt;p&gt;The launcher now starts every interactive shell and Jupyter process with the host UID and GID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; bash &lt;span class="nt"&gt;-lc&lt;/span&gt; &lt;span class="s1"&gt;'cd /workspace &amp;amp;&amp;amp; exec bash'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker accepts a numeric UID for &lt;code&gt;--user&lt;/code&gt;; only a user &lt;em&gt;name&lt;/em&gt; must exist in the container. A UID with no entry in the image's &lt;code&gt;/etc/passwd&lt;/code&gt; still runs, and that is where &lt;code&gt;I have no name!&lt;/code&gt; comes from. Tools that look up the user, &lt;code&gt;sudo&lt;/code&gt; among them, break too.&lt;/p&gt;

&lt;p&gt;So a separate root step runs first to make the image aware of the user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; 0 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
  grep -q ':x:&lt;/span&gt;&lt;span class="nv"&gt;$GID&lt;/span&gt;&lt;span class="s2"&gt;:' /etc/group  || echo '&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;:x:&lt;/span&gt;&lt;span class="nv"&gt;$GID&lt;/span&gt;&lt;span class="s2"&gt;:' &amp;gt;&amp;gt; /etc/group
  id &lt;/span&gt;&lt;span class="nv"&gt;$UID&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;/dev/null 2&amp;gt;&amp;amp;1        || echo '&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;:x:&lt;/span&gt;&lt;span class="nv"&gt;$UID&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$GID&lt;/span&gt;&lt;span class="s2"&gt;::/workspace:/bin/bash' &amp;gt;&amp;gt; /etc/passwd
  grep -q '^&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;:' /etc/shadow  || echo '&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;:*:19999:0:99999:7:::' &amp;gt;&amp;gt; /etc/shadow
  command -v sudo &amp;gt;/dev/null     || (apt-get update &amp;amp;&amp;amp; apt-get install -y sudo)
  echo '&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt; ALL=(ALL) NOPASSWD:ALL' &amp;gt; /etc/sudoers.d/lab &amp;amp;&amp;amp; chmod 0440 /etc/sudoers.d/lab
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line in that block fixed a failure we hit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We first used &lt;code&gt;useradd&lt;/code&gt;. Some images do not have it, so the script now writes to &lt;code&gt;/etc/passwd&lt;/code&gt; and &lt;code&gt;/etc/group&lt;/code&gt; directly.&lt;/li&gt;
&lt;li&gt;Images built on the Jupyter Docker Stacks start as &lt;code&gt;jovyan&lt;/code&gt; (UID 1000). Without &lt;code&gt;--user 0&lt;/code&gt;, the setup ran as &lt;code&gt;jovyan&lt;/code&gt; and could not write &lt;code&gt;/etc/passwd&lt;/code&gt;. The result was &lt;code&gt;I have no name!&lt;/code&gt; and a broken &lt;code&gt;sudo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A user that exists in &lt;code&gt;passwd&lt;/code&gt; but not in &lt;code&gt;shadow&lt;/code&gt; makes &lt;code&gt;sudo&lt;/code&gt; fail with &lt;code&gt;account validation failure&lt;/code&gt;. A non-expiring shadow line with a locked password fixes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This &lt;code&gt;sudo&lt;/code&gt; is container root only: users can &lt;code&gt;apt install&lt;/code&gt;, but on &lt;code&gt;/workspace&lt;/code&gt; it is the machine account and cannot read anyone's dataset. Access to the Docker daemon itself is a separate problem, covered in a &lt;a href="https://onurbaydas.online/posts/docker-authz-opa-en/" rel="noopener noreferrer"&gt;separate post on Docker authorization&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;jovyan&lt;/code&gt; case fails in a different way. The host has no ticket cache for UID 1000, so &lt;code&gt;rpc.gssd&lt;/code&gt; has nothing to present. The user's own UID is the only one that works correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The UID Mismatch
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Two ID mapping engines, two numbers
&lt;/h3&gt;

&lt;p&gt;AD identifies users by SID. Every Linux system has to turn a SID into a number, and ours used two different engines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the clients, SSSD.&lt;/strong&gt; With the AD provider, &lt;code&gt;ldap_id_mapping&lt;/code&gt; is true by default. The &lt;code&gt;sssd-ad(5)&lt;/code&gt; man page says the provider maps "UID and GID values from the objectSID parameter in Active Directory", and that "When ID-mapping is enabled, the uidNumber and gidNumber attributes are ignored." The default range runs from 200,000 to 2,000,200,000 in slices of 200,000. Each domain lands in a slice chosen by a hash of its SID, so the resulting UIDs are large and look arbitrary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On TrueNAS, winbind with the RID backend.&lt;/strong&gt; TrueNAS joins AD through Samba. Its documentation says that by default it "uses an algorithmic method based on the RID component of the user or group SID", and lists the default domain range as 100000001 to 200000000. Our TrueNAS used this default. The Samba man page gives the formula: &lt;code&gt;ID = RID - BASE_RID + LOW_RANGE_ID&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both engines are deterministic, but they start from different bases. For a user with RID 1105, the client computed &lt;code&gt;1914801105&lt;/code&gt; and TrueNAS computed &lt;code&gt;100001106&lt;/code&gt;. Neither was wrong, but they were not the same number. We first saw the two numbers side by side when we compared &lt;code&gt;id&lt;/code&gt; on a client with the owner of the user's files on TrueNAS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the mismatch hits, layer by layer
&lt;/h3&gt;

&lt;p&gt;The example UIDs are the values before the fix.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Where the identity comes from&lt;/th&gt;
&lt;th&gt;Example UID&lt;/th&gt;
&lt;th&gt;What the mismatch does here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Client host&lt;/td&gt;
&lt;td&gt;SSSD ID mapping from SID&lt;/td&gt;
&lt;td&gt;1914801105&lt;/td&gt;
&lt;td&gt;Consistent on the client. &lt;code&gt;id -u&lt;/code&gt; returns it, and the launcher passes it to the container. If it is ever sent to TrueNAS as a number, it points to the wrong account or to none.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker exec --user &amp;lt;host UID&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1914801105&lt;/td&gt;
&lt;td&gt;Without a &lt;code&gt;passwd&lt;/code&gt; entry you get &lt;code&gt;I have no name!&lt;/code&gt; and &lt;code&gt;sudo&lt;/code&gt; breaks. As root or &lt;code&gt;jovyan&lt;/code&gt;, NFS requests do not carry the user's identity.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NFS request&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rpc.gssd&lt;/code&gt; finds &lt;code&gt;/tmp/krb5cc_&amp;lt;UID&amp;gt;&lt;/code&gt;, and the principal goes on the wire&lt;/td&gt;
&lt;td&gt;&lt;code&gt;alice@EXAMPLE.LOCAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The number is only a lookup key on the client. The mismatch does not matter here, provided the ticket cache is named after the UID the process actually runs as.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TrueNAS file system&lt;/td&gt;
&lt;td&gt;Principal to UID through winbind idmap, checked against ZFS ownership&lt;/td&gt;
&lt;td&gt;100001106&lt;/td&gt;
&lt;td&gt;The dataset must be owned by &lt;em&gt;this&lt;/em&gt; number. If ownership is wrong, the user is locked out of their own &lt;code&gt;0700&lt;/code&gt; dataset.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TrueNAS API &lt;code&gt;filesystem.setperm&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Whatever the provisioning code sends&lt;/td&gt;
&lt;td&gt;100001106 (correct) or 1914801105 (wrong)&lt;/td&gt;
&lt;td&gt;Our name-based call did not apply. The dataset stayed &lt;code&gt;root:root 755&lt;/code&gt;, so the owner could not write and other users could read. Sending the client's UID would have been just as wrong.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NFSv4 name mapping (idmapd)&lt;/td&gt;
&lt;td&gt;Server sends &lt;code&gt;EXAMPLE\alice@example.local&lt;/code&gt;, and the client maps it through NSS (SSSD)&lt;/td&gt;
&lt;td&gt;client: 1914801105&lt;/td&gt;
&lt;td&gt;If the client's NFSv4 domain does not match, the name does not map and the file shows as &lt;code&gt;nobody&lt;/code&gt;. If it matches, the name maps to the client's own UID and the mismatch disappears from view.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Symptom 1: &lt;code&gt;nobody:nogroup&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The client logs were full of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nfsidmap: libnfsidmap: Unable to determine the NFSv4 domain; Using 'localdomain' as the NFSv4 domain ...
nfsidmap: nss_getpwnam: name 'EXAMPLE\alice@example.local' does not map into domain 'localdomain'
nfsidmap: nss_name_to_gid: name 'EXAMPLE\domain users@example.local' does not map into domain 'localdomain'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NFSv4 sends owners as &lt;code&gt;user@domain&lt;/code&gt; strings, not numbers (&lt;code&gt;nfsidmap(5)&lt;/code&gt;), and the client accepts only names from its own NFSv4 domain. If &lt;code&gt;idmapd.conf&lt;/code&gt; does not set one, the client tries a DNS &lt;code&gt;_nfsv4idmapdomain&lt;/code&gt; record, then the DNS domain. Ours found neither, fell back to &lt;code&gt;localdomain&lt;/code&gt;, and every owner became &lt;code&gt;nobody&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix was one line on every client, plus clearing the cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/idmapd.conf
&lt;/span&gt;&lt;span class="nn"&gt;[General]&lt;/span&gt;
&lt;span class="py"&gt;Domain&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;example.local&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nfsidmap &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nfsidmap -c&lt;/code&gt; clears the keyring of cached mappings. The man page warns that a stale key alone can make ownership show as &lt;code&gt;nobody&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After that fix, ownership &lt;em&gt;looked&lt;/em&gt; correct, and the mismatch became invisible: &lt;code&gt;ls -l&lt;/code&gt; showed &lt;code&gt;alice&lt;/code&gt;, &lt;code&gt;ls -ln&lt;/code&gt; showed &lt;code&gt;1914801105&lt;/code&gt;, and TrueNAS stored &lt;code&gt;100001106&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 2: the right owner, and still &lt;code&gt;Permission denied&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;At first, the share squashed all access to a single user (&lt;code&gt;mapall&lt;/code&gt;), which hid ownership problems. Once we removed &lt;code&gt;mapall&lt;/code&gt; and made the share Kerberos-only, isolation depended on one thing: each dataset's owner had to be exactly the UID that TrueNAS maps the user's principal to.&lt;/p&gt;

&lt;p&gt;The first test after that change looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ stat -c '%U %u' ~/nfs-home
alice 1914801105
$ touch ~/nfs-home/_t
touch: cannot touch '/home/alice/nfs-home/_t': Permission denied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client resolved the owner to the right user, because idmapd translated the name. The server still refused the write. Three things were wrong at the same time, and each one alone was enough to deny access:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The numbers differed.&lt;/strong&gt; TrueNAS stored the files under its own UID, which the clients never used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The client did not present the user's ticket.&lt;/strong&gt; The cache name had a random suffix (&lt;code&gt;/tmp/krb5cc_1914801105_XXXXXX&lt;/code&gt;), and &lt;code&gt;rpc.gssd&lt;/code&gt; did not find it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server mapped the principal to &lt;code&gt;nobody&lt;/code&gt;.&lt;/strong&gt; Even with the first two fixed, the user still got &lt;code&gt;ls: cannot open directory ...: Permission denied&lt;/code&gt;. As a diagnostic step, we set the directory to &lt;code&gt;777&lt;/code&gt; for a moment, and it opened. The user was getting in through the "other" bits, not as the owner. On TrueNAS, &lt;code&gt;getent passwd 'EXAMPLE\alice'&lt;/code&gt; resolved, but the bare &lt;code&gt;getent passwd alice&lt;/code&gt; returned nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix section covers all three.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 3: a new dataset owned by &lt;code&gt;root:root 755&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The provisioning code set permissions by name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mount_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EXAMPLE&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;alice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;group&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EXAMPLE&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;domain users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;700&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recursive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traverse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On our TrueNAS version the call was rejected, and new datasets stayed &lt;code&gt;root:root 755&lt;/code&gt;. Nobody noticed, because the provisioning code caught the error, logged it as non-critical, and carried on. Older datasets looked fine only because they had been fixed by hand. Under a Kerberos-only share, a &lt;code&gt;root:root 755&lt;/code&gt; dataset means the owner cannot write to their own home and any other authenticated user can read it. Newer TrueNAS API references list &lt;code&gt;user&lt;/code&gt; and &lt;code&gt;group&lt;/code&gt; name fields for &lt;code&gt;filesystem.setperm&lt;/code&gt;, so name-based calls may behave differently on your version. Numeric IDs avoid the question entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this stays quiet
&lt;/h3&gt;

&lt;p&gt;No error ever mentioned a UID. Every tool showed the right &lt;em&gt;name&lt;/em&gt;, the symptoms had different names in different layers (&lt;code&gt;nobody&lt;/code&gt;, &lt;code&gt;Permission denied&lt;/code&gt; on a file you own, &lt;code&gt;root:root 755&lt;/code&gt;, &lt;code&gt;I have no name!&lt;/code&gt;), and Kerberos kept the number off the wire. You only see the mismatch when you put the two numbers side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;The fix had three parts. First, make every layer carry the right identity. Second, make the two numbers equal. Third, fix the two lookups that still turned the user into &lt;code&gt;nobody&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part 1: the right identity at every hop
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Names on the wire.&lt;/strong&gt; Every client gets &lt;code&gt;Domain = example.local&lt;/code&gt; in &lt;code&gt;idmapd.conf&lt;/code&gt; and runs &lt;code&gt;nfsidmap -c&lt;/code&gt;, so the owner strings from TrueNAS resolve through SSSD to local users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Principals for access.&lt;/strong&gt; The share accepts only &lt;code&gt;KRB5&lt;/code&gt;, with no &lt;code&gt;mapall&lt;/code&gt;/&lt;code&gt;maproot&lt;/code&gt;. The clients mount with &lt;code&gt;sec=krb5&lt;/code&gt;, run &lt;code&gt;rpc.gssd&lt;/code&gt;, keep the ticket cache at a predictable &lt;code&gt;/tmp/krb5cc_%U&lt;/code&gt;, and renew it. Each of these broke at least once after the share became Kerberos-only:

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;pam_mount&lt;/code&gt; options did not include &lt;code&gt;sec=krb5&lt;/code&gt;, so the mount was refused.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rpc-gssd&lt;/code&gt; was not running on every client. It has to be enabled.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rpc.gssd&lt;/code&gt; logged &lt;code&gt;ERROR: unable to resolve 192.0.2.10 to hostname: Name or service not known&lt;/code&gt;. Without a reverse lookup it cannot build the NFS service principal for the server, and the mount fails. A PTR record in DNS fixes it, or an &lt;code&gt;/etc/hosts&lt;/code&gt; entry with the server's FQDN.&lt;/li&gt;
&lt;li&gt;Some users got &lt;code&gt;ls: cannot access '/home/alice/nfs-home/': Key has expired&lt;/code&gt;. They had logged in while SSSD could not reach the KDC, so they had no valid ticket, and nothing renewed it. The renewal settings shown earlier fix this, and the user has to log out and back in once.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user's UID inside the container.&lt;/strong&gt; Every process that touches &lt;code&gt;/workspace&lt;/code&gt; runs with &lt;code&gt;--user &amp;lt;host UID&amp;gt;:&amp;lt;host GID&amp;gt;&lt;/code&gt;. Root work happens in a separate &lt;code&gt;--user 0&lt;/code&gt; exec that touches only the container's own files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ownership from the server's own lookup.&lt;/strong&gt; Provisioning asks TrueNAS which UID it uses for the user, then sets ownership numerically:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;uid_resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/user/get_user_obj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;user_obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uid_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pw_uid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;user_obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pw_gid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;could not resolve uid/gid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mount_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;700&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;gid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recursive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traverse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/filesystem/setperm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;user.get_user_obj&lt;/code&gt; returns the &lt;code&gt;struct passwd&lt;/code&gt; fields for a user, including &lt;code&gt;pw_uid&lt;/code&gt; and &lt;code&gt;pw_gid&lt;/code&gt;, and it works for Active Directory accounts too. The TrueNAS API documents &lt;code&gt;uid&lt;/code&gt; and &lt;code&gt;gid&lt;/code&gt; in &lt;code&gt;filesystem.setperm&lt;/code&gt; as numeric owner IDs. If TrueNAS cannot resolve the user, provisioning stops instead of leaving a dataset owned by root.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part 2: align the UIDs by moving the TrueNAS range
&lt;/h3&gt;

&lt;p&gt;Both engines add the user's RID to a fixed base. TrueNAS uses &lt;code&gt;range_low - BASE_RID&lt;/code&gt;. SSSD uses the start of the slice the domain landed in. The &lt;code&gt;sssd-ad(5)&lt;/code&gt; page requires &lt;code&gt;ldap_idmap_range_size&lt;/code&gt; to be at least the highest RID in the domain, so every user fits in that one slice. If TrueNAS starts its range at the same base, the numbers become equal.&lt;/p&gt;

&lt;p&gt;We found the SSSD base by subtracting the RID from the client's UID, for a user and for two groups. All three gave the same number, which confirmed that the client's formula was &lt;code&gt;base + RID&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alice          1914801105 - 1105 = 1914800000
domain users   1914800513 -  513 = 1914800000
another group  1914801158 - 1158 = 1914800000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we changed the TrueNAS side:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the Active Directory idmap settings for the domain, keep the &lt;strong&gt;RID&lt;/strong&gt; backend and set &lt;strong&gt;Range Low&lt;/strong&gt; to the base and &lt;strong&gt;Range High&lt;/strong&gt; to the base plus the SSSD slice size (200,000). The RID backend's SSSD compatibility option stays off; the range is already aligned.&lt;/li&gt;
&lt;li&gt;TrueNAS would not save idmap changes while the directory service was enabled. Disable the directory service, save, then enable it again.&lt;/li&gt;
&lt;li&gt;Enabling it makes TrueNAS rejoin the domain. The account used for the join needs write access to the TrueNAS computer object. Without it, the join fails with &lt;code&gt;Insufficient access&lt;/code&gt; during &lt;code&gt;LDB_MODIFY&lt;/code&gt;. A &lt;code&gt;KRB5KDC_ERR_PREAUTH_FAILED&lt;/code&gt; at this step means a wrong password, a wrong UPN format, or a clock difference larger than Kerberos allows.&lt;/li&gt;
&lt;li&gt;Verify: &lt;code&gt;wbinfo -i 'EXAMPLE\alice'&lt;/code&gt; on TrueNAS must show the same UID as &lt;code&gt;id -u alice&lt;/code&gt; on a client.&lt;/li&gt;
&lt;li&gt;Re-own the existing data, because every AD UID on TrueNAS has changed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With the example numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client (SSSD):    alice  RID 1105  -&amp;gt;  1914801105
TrueNAS before:   alice  RID 1105  -&amp;gt;  100001106    (range 100000001-200000000)
TrueNAS after:    alice  RID 1105  -&amp;gt;  1914801105   (range 1914800000-1915000000)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The re-own step is not optional. ZFS stores numeric owners, so every existing dataset still pointed to the old TrueNAS UIDs. Because the old and the new TrueNAS formulas are both &lt;code&gt;base + RID&lt;/code&gt;, the shift is the same for every account: &lt;code&gt;1914800000 - 100000001 = 1814799999&lt;/code&gt;. We took a recursive ZFS snapshot first, then added that shift to every UID and GID in the old range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zfs snapshot &lt;span class="nt"&gt;-r&lt;/span&gt; pool/users@before-idmap-fix
&lt;span class="nv"&gt;OLD_LOW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100000001&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;OLD_HIGH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200000000&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;DELTA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1814799999
&lt;span class="k"&gt;for &lt;/span&gt;U &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;find /mnt/pool/users &lt;span class="nt"&gt;-printf&lt;/span&gt; &lt;span class="s1"&gt;'%U\n'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-un&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$U&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OLD_LOW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$U&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-le&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OLD_HIGH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    find /mnt/pool/users &lt;span class="nt"&gt;-uid&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$U&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;U &lt;span class="o"&gt;+&lt;/span&gt; DELTA&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; +
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="c"&gt;# the same loop with %G, -gid and chgrp for groups&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. Each user's home was its own child dataset, so &lt;code&gt;find&lt;/code&gt; must not use &lt;code&gt;-xdev&lt;/code&gt;, or it stops at the first dataset boundary. Local accounts with small UIDs are outside the old range, so the loop leaves them alone.&lt;/p&gt;

&lt;p&gt;After the change, the UID was the same for every user we checked on the clients, in the containers, and on TrueNAS. &lt;code&gt;ls -ln&lt;/code&gt; now shows the same number on both sides. Even when a number does cross the boundary, it identifies the right person.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part 3: the two lookups that still returned &lt;code&gt;nobody&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Aligned numbers were not enough. The user from Symptom 2 still got &lt;code&gt;Permission denied&lt;/code&gt;, for two more reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the client, a ticket cache that &lt;code&gt;rpc.gssd&lt;/code&gt; could find.&lt;/strong&gt; SSSD had written the ticket to &lt;code&gt;/tmp/krb5cc_1914801105_XXXXXX&lt;/code&gt;, with a random suffix. &lt;code&gt;rpc.gssd&lt;/code&gt; did not use it. Setting &lt;code&gt;krb5_ccname_template = FILE:%d/krb5cc_%U&lt;/code&gt; (shown earlier) gave the cache a fixed name. We rolled it out to every client and restarted SSSD. &lt;code&gt;klist&lt;/code&gt; then showed &lt;code&gt;/tmp/krb5cc_1914801105&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On TrueNAS, "Use Default Domain".&lt;/strong&gt; With this setting off, TrueNAS resolved only the domain-qualified name (&lt;code&gt;EXAMPLE\alice&lt;/code&gt;), and the bare &lt;code&gt;alice&lt;/code&gt; returned nothing. Incoming principals ended up as &lt;code&gt;nobody&lt;/code&gt;, which is why the &lt;code&gt;777&lt;/code&gt; test opened the directory. With the setting on, the bare name resolved to the aligned UID and the &lt;code&gt;0700&lt;/code&gt; home opened for its owner.&lt;/p&gt;

&lt;p&gt;Changing it had its own catch. TrueNAS refused to disable the directory service while it held a Kerberos user credential: &lt;code&gt;Kerberos user credentials may not be stored for disabled directory services&lt;/code&gt;. We first switched the credential to the TrueNAS machine account's Kerberos principal (its keytab), then disabled, changed the setting, and enabled again. That also removed the password and &lt;code&gt;PREAUTH_FAILED&lt;/code&gt; problems from later rejoins.&lt;/p&gt;

&lt;p&gt;Old mappings are cached on both sides, so after the change we flushed them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# TrueNAS&lt;/span&gt;
net cache flush
nfsidmap &lt;span class="nt"&gt;-c&lt;/span&gt;
midclt call service.restart nfs
&lt;span class="c"&gt;# clients: remount, or log out and back in&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few limits apply to this trick:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It depends on SSSD's slice choice. All clients must use the same SSSD ID mapping settings so they all land in the same slice. The man page warns that hash collisions can make slices differ between machines, so check more than one client.&lt;/li&gt;
&lt;li&gt;Users whose RID is above the SSSD range size do not fit the scheme. The man page says lookups fail for them.&lt;/li&gt;
&lt;li&gt;The TrueNAS docs warn that the domain range "must not overlap with local accounts or trusted domain ranges", and that idmap mistakes can assign permissions to the wrong users. Make the change in a maintenance window and verify before users log in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to Check Your Own Setup
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On the client, find out who you are:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;id&lt;/span&gt;                    &lt;span class="c"&gt;# UID and GID as SSSD computed them&lt;/span&gt;
getent passwd alice   &lt;span class="c"&gt;# the same, from NSS&lt;/span&gt;
klist                 &lt;span class="c"&gt;# the principal and the ticket cache path; it should end in krb5cc_&amp;lt;your UID&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the cache name does not contain your &lt;code&gt;id -u&lt;/code&gt;, &lt;code&gt;rpc.gssd&lt;/code&gt; cannot find it; check &lt;code&gt;krb5_ccname_template&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On TrueNAS, find out who the server thinks you are&lt;/strong&gt; (from the TrueNAS shell):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="s1"&gt;'EXAMPLE\alice'&lt;/span&gt;
midclt call user.get_user_obj &lt;span class="s1"&gt;'{"username": "alice"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare &lt;code&gt;pw_uid&lt;/code&gt; with the client's &lt;code&gt;id -u&lt;/code&gt;. If they differ, subtract the RID from the client's UID for two or three accounts. If the result is the same for all of them, that number is the client's base, and the range change above will work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the NFS mount and the name mapping:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;findmnt &lt;span class="nt"&gt;-t&lt;/span&gt; nfs4 &lt;span class="nt"&gt;-o&lt;/span&gt; TARGET,SOURCE,OPTIONS   &lt;span class="c"&gt;# look for sec=krb5 and vers=4.x&lt;/span&gt;
nfsidmap &lt;span class="nt"&gt;-d&lt;/span&gt;                                &lt;span class="c"&gt;# effective NFSv4 domain; must not be "localdomain"&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^\s*Domain'&lt;/span&gt; /etc/idmapd.conf
systemctl is-active rpc-gssd
journalctl &lt;span class="nt"&gt;-t&lt;/span&gt; nfsidmap &lt;span class="nt"&gt;--since&lt;/span&gt; today       &lt;span class="c"&gt;# "does not map into domain" means wrong domain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the share is Kerberos-only and you still get &lt;code&gt;Permission denied&lt;/code&gt; on a directory you own, check the server's view of the bare user name (on TrueNAS, &lt;code&gt;getent passwd alice&lt;/code&gt; without the domain), and check that &lt;code&gt;klist&lt;/code&gt; shows a cache without a random suffix. To get more detail, stop &lt;code&gt;rpc-gssd&lt;/code&gt; and run &lt;code&gt;rpc.gssd -f -vvv&lt;/code&gt; in the foreground while you repeat the access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check ownership both ways:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;  ~/nfs-home     &lt;span class="c"&gt;# names: should show alice, never nobody&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-ln&lt;/span&gt; ~/nfs-home     &lt;span class="c"&gt;# numbers: after alignment, the same UID as on TrueNAS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On TrueNAS, &lt;code&gt;ls -ln /mnt/pool/users/alice&lt;/code&gt; should show the same UID with mode &lt;code&gt;drwx------&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside the container:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; mybox sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'id; whoami; ls -ln /workspace | head'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;whoami&lt;/code&gt; should print a name, and &lt;code&gt;/workspace&lt;/code&gt; should show the same UID. Repeat with &lt;code&gt;--user 0&lt;/code&gt;: listing &lt;code&gt;/workspace&lt;/code&gt; should fail. If it succeeds, check the share's &lt;code&gt;maproot&lt;/code&gt; and the dataset's mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternatives
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SSSD on POSIX attributes.&lt;/strong&gt; Store &lt;code&gt;uidNumber&lt;/code&gt;/&lt;code&gt;gidNumber&lt;/code&gt; in AD for every user, set &lt;code&gt;ldap_id_mapping = False&lt;/code&gt; in SSSD, and select the AD (RFC2307) idmap backend on TrueNAS. Both sides read the same stored number, independent of any slice or range. The costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every account needs those attributes, assigned uniquely and forever by your provisioning.&lt;/li&gt;
&lt;li&gt;The SSSD man page recommends replicating them to the Global Catalog for performance.&lt;/li&gt;
&lt;li&gt;The man page warns that changing ID mapping options "will cause user and group IDs to change", so every home directory on the clients has to be re-owned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Two UID spaces.&lt;/strong&gt; Leave the numbers different and rely on Kerberos plus idmapd names, always setting ownership from the server's own lookup. Nothing is migrated, but everyone must know never to send a client UID to the server, which rules out &lt;code&gt;sec=sys&lt;/code&gt;, NFSv3, and careless numeric backups. We ran like this for a while; it is how the mismatch stayed hidden. (Replacing SSSD with winbind on the clients would also give one algorithm on both sides, at the cost of a new identity stack.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The approach we used: move the TrueNAS RID range.&lt;/strong&gt; It changes one setting on one server, and it touches neither AD nor the clients. The cost is a one-time re-own of the existing datasets, a rejoin of the NAS to the domain, and a dependency on SSSD's slice. If the SSSD ID mapping settings change, or a client lands in a different slice, the mismatch comes back. For a lab with one NAS and identical clients, that was the cheapest way to get one UID per user everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;With NFSv4 and Kerberos, identity on the wire is a principal plus a name string, and a numeric UID exists only inside each system. That let our clients and our NAS disagree about every UID with nothing visibly wrong. The problems appeared where a number or a name crossed a boundary: an idmapd domain that defaulted to &lt;code&gt;localdomain&lt;/code&gt;, a ticket cache that &lt;code&gt;rpc.gssd&lt;/code&gt; could not find, a server that could not resolve the bare user name, a permission call that failed silently, and containers that ran as root or as the image's built-in user instead of the person at the keyboard.&lt;/p&gt;

&lt;p&gt;Three rules came out of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run container processes that touch Kerberos NFS as the user's host UID, and keep root work inside the container.&lt;/li&gt;
&lt;li&gt;Set ownership on the server with the UID the server itself resolves.&lt;/li&gt;
&lt;li&gt;Compare &lt;code&gt;id -u&lt;/code&gt; on the client with the server's UID for the same user. If they differ, compute the client's base (UID minus RID), start the server's RID range at that base, and re-own the data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rpc.gssd(8)&lt;/code&gt;, credential cache lookup and machine credentials for UID 0: &lt;a href="https://man7.org/linux/man-pages/man8/rpc.gssd.8.html" rel="noopener noreferrer"&gt;https://man7.org/linux/man-pages/man8/rpc.gssd.8.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nfsidmap(5)&lt;/code&gt;, NFSv4 &lt;code&gt;user@domain&lt;/code&gt; names, &lt;code&gt;-c&lt;/code&gt;, &lt;code&gt;-d&lt;/code&gt;, stale keys showing as nobody: &lt;a href="https://man7.org/linux/man-pages/man5/nfsidmap.5.html" rel="noopener noreferrer"&gt;https://man7.org/linux/man-pages/man5/nfsidmap.5.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;idmapd.conf(5)&lt;/code&gt;, &lt;code&gt;Domain&lt;/code&gt; and its default: &lt;a href="https://linux.die.net/man/5/idmapd.conf" rel="noopener noreferrer"&gt;https://linux.die.net/man/5/idmapd.conf&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sssd-ad(5)&lt;/code&gt;, &lt;code&gt;ldap_id_mapping&lt;/code&gt;, ID ranges and slices, &lt;code&gt;ldap_idmap_range_size&lt;/code&gt;: &lt;a href="https://man.archlinux.org/man/sssd-ad.5.en" rel="noopener noreferrer"&gt;https://man.archlinux.org/man/sssd-ad.5.en&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sssd-krb5(5)&lt;/code&gt;, &lt;code&gt;krb5_ccname_template&lt;/code&gt;, renewal options: &lt;a href="https://man.archlinux.org/man/sssd-krb5.5.en" rel="noopener noreferrer"&gt;https://man.archlinux.org/man/sssd-krb5.5.en&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Samba &lt;code&gt;idmap_rid(8)&lt;/code&gt;: &lt;a href="https://www.samba.org/samba/docs/current/man-html/idmap_rid.8.html" rel="noopener noreferrer"&gt;https://www.samba.org/samba/docs/current/man-html/idmap_rid.8.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TrueNAS, Configuring Active Directory (idmap backends and warnings): &lt;a href="https://www.truenas.com/docs/scale/credentials/directoryservices/configad/" rel="noopener noreferrer"&gt;https://www.truenas.com/docs/scale/credentials/directoryservices/configad/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TrueNAS, Directory Services Screens (Range Low/High, default ranges): &lt;a href="https://www.truenas.com/docs/scale/credentials/directoryservices/directoryservicesscreens/" rel="noopener noreferrer"&gt;https://www.truenas.com/docs/scale/credentials/directoryservices/directoryservicesscreens/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TrueNAS API, &lt;code&gt;filesystem.setperm&lt;/code&gt;: &lt;a href="https://api.truenas.com/v25.10/api_methods_filesystem.setperm.html" rel="noopener noreferrer"&gt;https://api.truenas.com/v25.10/api_methods_filesystem.setperm.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;TrueNAS API, &lt;code&gt;user.get_user_obj&lt;/code&gt;: &lt;a href="https://api.truenas.com/v25.10/api_methods_user.get_user_obj.html" rel="noopener noreferrer"&gt;https://api.truenas.com/v25.10/api_methods_user.get_user_obj.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker, running containers (default user, &lt;code&gt;--user&lt;/code&gt;, numeric UIDs): &lt;a href="https://docs.docker.com/engine/containers/run/" rel="noopener noreferrer"&gt;https://docs.docker.com/engine/containers/run/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker CLI, &lt;code&gt;docker container exec&lt;/code&gt;: &lt;a href="https://docs.docker.com/reference/cli/docker/container/exec/" rel="noopener noreferrer"&gt;https://docs.docker.com/reference/cli/docker/container/exec/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>Docker Authorization with OPA: The Silent Fail-Open and Other Production Lessons</title>
      <dc:creator>onurbaydas</dc:creator>
      <pubDate>Mon, 12 Oct 2026 15:02:10 +0000</pubDate>
      <link>https://dev.to/onurbaydas/docker-authorization-with-opa-the-silent-fail-open-and-other-production-lessons-23n7</link>
      <guid>https://dev.to/onurbaydas/docker-authorization-with-opa-the-silent-fail-open-and-other-production-lessons-23n7</guid>
      <description>&lt;p&gt;&lt;em&gt;Bu yazının Türkçesi: &lt;a href="https://onurbaydas.online/posts/docker-authz-opa/" rel="noopener noreferrer"&gt;OPA ile Docker Authorization&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Introduction
&lt;/h2&gt;

&lt;p&gt;I set up &lt;code&gt;opa-docker-authz&lt;/code&gt; to restrict Docker on shared, multi-user Linux workstations. After a routine policy update, I noticed that &lt;code&gt;--privileged&lt;/code&gt; containers were silently running. This article summarizes that incident and what I found in the source code afterward.&lt;/p&gt;

&lt;p&gt;Docker authorization plugins let you evaluate every API request sent to the Docker daemon against a policy before the daemon processes it. &lt;code&gt;opa-docker-authz&lt;/code&gt; is a plugin that implements this mechanism with OPA (Open Policy Agent) and its policy language, Rego.&lt;/p&gt;

&lt;p&gt;This article covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How the Docker authorization plugin mechanism works, and how to install &lt;code&gt;opa-docker-authz&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;What an example policy guarantees and what it doesn't.&lt;/li&gt;
&lt;li&gt;Four pitfalls: silent fail-open, policy updates, Rego syntax and version compatibility, and fields the policy can't see.&lt;/li&gt;
&lt;li&gt;Protections an authorization plugin can't provide by design.&lt;/li&gt;
&lt;li&gt;A production checklist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fail-open means a control mechanism lets a request through on error instead of rejecting it. The main subject of this article is that this can happen without a single error or warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Scenario and Goal
&lt;/h2&gt;

&lt;p&gt;The scenario: multi-user, shared Linux workstations. Users need Docker, but they shouldn't be able to access each other's data or the host.&lt;/p&gt;

&lt;p&gt;The core tension is this: the Docker daemon (dockerd) runs as root. The daemon is the background service that handles Docker API requests. Anyone who can reach the daemon's socket can ask it to do work with root privileges. Docker's official documentation also states that the &lt;code&gt;docker&lt;/code&gt; group grants a user root-level privileges. In this scenario, users have access to the socket.&lt;/p&gt;

&lt;p&gt;The goal is a single rule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Each user can bind only their own workspace (&lt;code&gt;/home/&amp;lt;user&amp;gt;/workspace&lt;/code&gt;) to &lt;code&gt;/workspace&lt;/code&gt; inside a container. No other bind mounts, no privileged containers, no host namespaces. GPU access is allowed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A bind mount attaches a directory on the host directly into a container. The container sees that directory as part of its own file system. If the container runs as root, it can read and write everything in that directory.&lt;/p&gt;

&lt;p&gt;Expected usage looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gpus&lt;/span&gt; all &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"/home/&amp;lt;user&amp;gt;/workspace"&lt;/span&gt;:/workspace &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&amp;lt;image&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because users have terminal access, the policy has to account for every request that can reach the Docker API, not just this command.&lt;/p&gt;

&lt;p&gt;The approach in this article is to put an authorization layer in front of a standard dockerd running as root. Alternatives such as rootless Docker and userns-remap are covered in Section 10.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. How Docker Authorization Plugins Work
&lt;/h2&gt;

&lt;p&gt;Docker's default access model is all-or-nothing. Any client that can reach the daemon can make any API call. Authorization plugins add a decision point to this model. Before processing each API request, the daemon asks the configured plugins whether they allow it.&lt;/p&gt;

&lt;p&gt;Request flow:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Sequence diagram: see the &lt;a href="https://onurbaydas.online/posts/docker-authz-opa-en/#3-how-docker-authorization-plugins-work" rel="noopener noreferrer"&gt;original post&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Details that matter for the rest of the article:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If the chain is empty, there is no check.&lt;/strong&gt; &lt;code&gt;WrapHandler&lt;/code&gt; in Moby's &lt;code&gt;pkg/authorization/middleware.go&lt;/code&gt; passes the request straight to the handler when the plugin list is empty. The middleware has no mechanism to warn that "a plugin should be here but isn't." This is the foundation of the pitfall in Section 6.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The middleware fails closed.&lt;/strong&gt; According to Docker's official documentation, if a plugin in the chain can't be reached, the request is denied. Docker also recommends that plugin authors fail closed. So a "the plugin crashed and everything opened up" scenario isn't expected at the middleware level. The risk is the plugin being removed from the chain without any warning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User identity is empty in most setups.&lt;/strong&gt; The request sent to the plugin has &lt;code&gt;User&lt;/code&gt; and &lt;code&gt;AuthenticationMethod&lt;/code&gt; fields. According to the Moby source code, these fields are populated only if the request arrived over TLS and the client presented a client certificate. The username comes from the certificate's CN field. For requests over the Unix socket, which covers nearly all local &lt;code&gt;docker&lt;/code&gt; commands, both fields are empty strings. The policy can't answer the question "who made this request?" The consequences are covered in Section 10.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plugin doesn't see everything.&lt;/strong&gt; According to the official documentation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The body is sent to the plugin only when it can be forwarded as JSON.&lt;/li&gt;
&lt;li&gt;For hijacked connections such as &lt;code&gt;exec&lt;/code&gt; and &lt;code&gt;attach&lt;/code&gt;, the plugin sees only the initial HTTP request.&lt;/li&gt;
&lt;li&gt;For streamed responses such as &lt;code&gt;logs&lt;/code&gt; and &lt;code&gt;events&lt;/code&gt;, it sees only the request.&lt;/li&gt;
&lt;li&gt;The gRPC endpoint that BuildKit uses isn't subject to authorization. A policy that tries to restrict image builds through authorization can't see this channel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An endpoint is a path in the Docker API that performs a specific operation, for example &lt;code&gt;/containers/create&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Installation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;opa-docker-authz&lt;/code&gt; is an authorization plugin with OPA embedded that you can install as a Docker managed plugin. The examples use the &lt;code&gt;openpolicyagent/opa-docker-authz-v2:0.9&lt;/code&gt; tag. This version ships with OPA 0.60. Versioning is covered separately in Section 8.&lt;/p&gt;

&lt;p&gt;Installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker plugin &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--alias&lt;/span&gt; opa-docker-authz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--grant-all-permissions&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  openpolicyagent/opa-docker-authz-v2:0.9 &lt;span class="se"&gt;\&lt;/span&gt;
  opa-args&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-policy-file /opa/policies/authz.rego"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy path needs care. The plugin's configuration mounts the host's &lt;code&gt;/etc/docker&lt;/code&gt; directory read-only at &lt;code&gt;/opa&lt;/code&gt; inside the plugin. A file at &lt;code&gt;/etc/docker/policies/authz.rego&lt;/code&gt; on the host appears as &lt;code&gt;/opa/policies/authz.rego&lt;/code&gt; from inside the plugin. &lt;code&gt;opa-args&lt;/code&gt; must contain the path inside the plugin, not the host path. If the path is wrong, the plugin allows every request (Section 6).&lt;/p&gt;

&lt;p&gt;File and directory permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 &lt;span class="nt"&gt;-o&lt;/span&gt; root &lt;span class="nt"&gt;-g&lt;/span&gt; root /etc/docker/policies
&lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0644 &lt;span class="nt"&gt;-o&lt;/span&gt; root &lt;span class="nt"&gt;-g&lt;/span&gt; root authz.rego /etc/docker/policies/authz.rego
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To add the plugin to the daemon's authorization plugin chain, use &lt;code&gt;daemon.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authorization-plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"opa-docker-authz"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a &lt;code&gt;daemon.json&lt;/code&gt; already exists, merge this key with the other settings. The order is: install the plugin first, then update &lt;code&gt;daemon.json&lt;/code&gt;, then make the daemon re-read its configuration. You can restart the service for this. &lt;code&gt;systemctl reload docker&lt;/code&gt; also rebuilds the chain and doesn't stop running containers (Section 6).&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Policy, Line by Line
&lt;/h2&gt;

&lt;p&gt;The policy below uses OPA 0.60's classic Rego syntax.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="ow"&gt;package&lt;/span&gt; &lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authz&lt;/span&gt;

&lt;span class="ow"&gt;default&lt;/span&gt; &lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_container_create&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;is_container_create&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;is_container_create&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;
    &lt;span class="n"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"?"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;"/containers/create"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;package docker.authz&lt;/code&gt; and the &lt;code&gt;allow&lt;/code&gt; rule form the decision point the plugin queries by default: &lt;code&gt;data.docker.authz.allow&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;default allow = false&lt;/code&gt; is a safe default. If no &lt;code&gt;allow&lt;/code&gt; rule matches, the decision is deny.&lt;/p&gt;

&lt;p&gt;The next two lines define the policy's architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the request is &lt;strong&gt;not&lt;/strong&gt; a container create request, allow it.&lt;/li&gt;
&lt;li&gt;If it is a container create request and no &lt;code&gt;deny&lt;/code&gt; rule fired, allow it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;is_container_create&lt;/code&gt; uses &lt;code&gt;endswith&lt;/code&gt; to skip the API version prefix (such as &lt;code&gt;/v1.47/&lt;/code&gt;) and &lt;code&gt;split&lt;/code&gt; to strip query parameters (&lt;code&gt;?name=...&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Hard prohibitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Privileged&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CapAdd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PidMode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"host"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NetworkMode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"host"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IpcMode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"host"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTSMode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"host"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UsernsMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Devices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SecurityOpt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"unconfined"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SecurityOpt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"no-new-privileges:false"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SecurityOpt&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"no-new-privileges=false"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These rules block &lt;code&gt;--privileged&lt;/code&gt;, &lt;code&gt;--cap-add&lt;/code&gt;, sharing the host's PID/network/IPC/UTS namespaces, disabling the user namespace, passing devices through with &lt;code&gt;--device&lt;/code&gt;, and setting protections such as seccomp/AppArmor to &lt;code&gt;unconfined&lt;/code&gt;. Each one on its own can be enough to escape from a container to the host.&lt;/p&gt;

&lt;p&gt;The bind mount rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Binds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;bind_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"bind"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;mount_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;bind_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;":"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"/workspace"&lt;/span&gt;
    &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
    &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"home"&lt;/span&gt;
    &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"workspace"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;mount_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Target&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"/workspace"&lt;/span&gt;
    &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
    &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"home"&lt;/span&gt;
    &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"workspace"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Docker, a bind mount can come from two separate fields. The &lt;code&gt;-v source:target&lt;/code&gt; syntax writes to the &lt;code&gt;HostConfig.Binds&lt;/code&gt; array, and the &lt;code&gt;--mount type=bind,...&lt;/code&gt; syntax writes to the &lt;code&gt;HostConfig.Mounts&lt;/code&gt; array. The policy checks both.&lt;/p&gt;

&lt;p&gt;When the source path is split on &lt;code&gt;/&lt;/code&gt;, it must have exactly four parts: &lt;code&gt;""&lt;/code&gt;, &lt;code&gt;"home"&lt;/code&gt;, the username, and &lt;code&gt;"workspace"&lt;/code&gt;. The target must be &lt;code&gt;/workspace&lt;/code&gt;. This also rejects deeper paths such as &lt;code&gt;/home/&amp;lt;user&amp;gt;/workspace/subdir&lt;/code&gt;. The reason: Docker resolves symbolic links (symlinks) in the bind source. Before this rule was added, the following was seen in production: when a symlink pointing to the host's root was placed inside the workspace and that subpath was bind-mounted, the host's files could be read. The four-part rule closes off symlinks that come in through a subpath. The workspace directory itself being a symlink is a separate risk (Section 10).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it guarantees&lt;/strong&gt; (verified on a live daemon): &lt;code&gt;--privileged&lt;/code&gt;, &lt;code&gt;--cap-add&lt;/code&gt;, binding the host root or &lt;code&gt;/etc&lt;/code&gt;, the subpath symlink, binding the Docker socket into a container, &lt;code&gt;--device&lt;/code&gt;, &lt;code&gt;--ipc=host&lt;/code&gt;, and binding outside the workspace with &lt;code&gt;--mount type=bind&lt;/code&gt; are all denied. Expected usage (the workspace bind and &lt;code&gt;--gpus all&lt;/code&gt;) works. When the machine reboots, the plugin comes back and the rules keep being enforced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it doesn't guarantee&lt;/strong&gt; is covered in Sections 9 and 10. Some of it comes from gaps in the policy, and some comes from the mechanism itself. First, though, consider the case where protection can shut off entirely even when the policy is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Pitfall 1: Silent Fail-Open
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom
&lt;/h3&gt;

&lt;p&gt;The plugin shows as enabled in &lt;code&gt;docker plugin ls&lt;/code&gt;, &lt;code&gt;authorization-plugins&lt;/code&gt; is set in &lt;code&gt;daemon.json&lt;/code&gt;, and the policy file is correct. Yet &lt;code&gt;docker run --rm --privileged alpine true&lt;/code&gt; succeeds. Host bind mounts aren't denied either. The daemon log has no error or warning.&lt;/p&gt;

&lt;p&gt;This happens after running this pair of commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker plugin disable &lt;span class="nt"&gt;--force&lt;/span&gt; opa-docker-authz
docker plugin &lt;span class="nb"&gt;enable &lt;/span&gt;opa-docker-authz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pair is a common habit, used to "reload the plugin so it picks up the new policy."&lt;/p&gt;

&lt;h3&gt;
  
  
  Mechanism: The Plugin Is Dropped from the Chain
&lt;/h3&gt;

&lt;p&gt;Three pieces of the Moby source code explain this behavior:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;Disable()&lt;/code&gt; function in &lt;code&gt;daemon/pkg/plugin/backend_linux.go&lt;/code&gt; calls &lt;code&gt;AuthzMiddleware.RemovePlugin(name)&lt;/code&gt; when a plugin of the authorization type is disabled. The plugin is removed from dockerd's authorization plugin chain.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Enable()&lt;/code&gt; function in the same file has &lt;strong&gt;no&lt;/strong&gt; call that adds the plugin back to the middleware. The plugin is enabled and &lt;code&gt;docker plugin ls&lt;/code&gt; shows it as enabled, but the plugin isn't in the chain.&lt;/li&gt;
&lt;li&gt;The config reload path in &lt;code&gt;daemon/command/daemon.go&lt;/code&gt; calls &lt;code&gt;authzMiddleware.SetPlugins(cfg.AuthorizationPlugins)&lt;/code&gt;. This is where the chain is rebuilt from the list in &lt;code&gt;daemon.json&lt;/code&gt;. &lt;code&gt;systemctl reload docker&lt;/code&gt; sends SIGHUP to the daemon and triggers this path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With the chain empty, the middleware passes every request straight to the handler (Section 3). As a result, authorization is silently off after disable/enable. The daemon reports no error, the plugin looks enabled, and the policy file is correct, but no request reaches the plugin. The behavior is the same in the current Moby source.&lt;/p&gt;

&lt;p&gt;In short: &lt;strong&gt;&lt;code&gt;docker plugin disable&lt;/code&gt; turns authorization off, &lt;code&gt;docker plugin enable&lt;/code&gt; doesn't turn it back on, and &lt;code&gt;systemctl reload docker&lt;/code&gt; does.&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  History: Why Does Removal from the Chain Exist?
&lt;/h4&gt;

&lt;p&gt;Removal from the chain isn't a bug; it's a fix. Moby issue #31836, opened in 2017, reported that after an authorization plugin was disabled, the daemon still sent requests to that plugin. Because the plugin no longer responded, all API requests were blocked.&lt;/p&gt;

&lt;p&gt;The fixing commit, &lt;code&gt;38de272bd4&lt;/code&gt;, added code to &lt;code&gt;Disable()&lt;/code&gt; that removes an authorization-type plugin from the middleware chain. The same commit didn't add code to &lt;code&gt;Enable()&lt;/code&gt; that puts the plugin back in the chain. This first fix removed the plugin from the chain with different code; later the same year, moby #33655 ("Eliminate authz plugin disable race") moved that code into today's &lt;code&gt;RemovePlugin&lt;/code&gt; function, and the behavior stayed the same. The silent fail-open described in this section is a side effect of that asymmetry in the fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observed Behavior
&lt;/h3&gt;

&lt;p&gt;Test results on a live daemon match the mechanism in the source code:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;--privileged&lt;/code&gt; test&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Policy written + &lt;code&gt;systemctl reload docker&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy changed + &lt;code&gt;disable&lt;/code&gt; / &lt;code&gt;enable&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Succeeded (fail-open)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Directory set to 755, file to 644 + &lt;code&gt;disable&lt;/code&gt; / &lt;code&gt;enable&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Succeeded (fail-open)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin reinstalled (&lt;code&gt;disable&lt;/code&gt; → &lt;code&gt;rm&lt;/code&gt; → &lt;code&gt;install&lt;/code&gt;) + &lt;code&gt;systemctl reload docker&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Denied&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The behavior is the same in a current version: with Docker Engine 29.9.0 and opa-docker-authz v0.9, &lt;code&gt;--privileged&lt;/code&gt; succeeds after disable/enable and is denied after &lt;code&gt;systemctl reload docker&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the last step, the chain is rebuilt by the reload, not the reinstall. A reinstall also includes a disable/enable cycle and doesn't add the plugin back to the chain on its own. The plugin is added back by &lt;code&gt;SetPlugins&lt;/code&gt;, that is, during the reload.&lt;/p&gt;

&lt;p&gt;The daemon journal has a notable entry: the plugin allowed the &lt;code&gt;POST .../plugins/opa-docker-authz/disable?force=1&lt;/code&gt; request that disabled it (&lt;code&gt;result: true&lt;/code&gt;). Because this policy only checks container creation, the disable request also passes through authorization and gets allowed. After this entry, there are no decision entries for the test requests: requests no longer reach the plugin.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Common Misdiagnosis: File Permissions
&lt;/h3&gt;

&lt;p&gt;A frequent explanation for this symptom goes like this: "If the policy file isn't 644, the plugin can't read it, runs with an empty policy, and allows everything." This explanation is wrong for two reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In the table above, protection was still off after disable/enable even once permissions were set to 644. The step that fixed it was the reload.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;opa-docker-authz&lt;/code&gt; reads the policy file with &lt;code&gt;os.ReadFile&lt;/code&gt;. If the read fails (including a permission error), it &lt;strong&gt;denies&lt;/strong&gt; the request. A permission problem produces a fail-closed result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A likely source of the confusion: the official &lt;code&gt;openpolicyagent/opa&lt;/code&gt; CLI image runs as a non-root user. Running &lt;code&gt;opa check&lt;/code&gt; with this image on a policy file with 600 permissions returns "permission denied." That says nothing about the plugin's behavior. The 755/644 permissions are a recommended hygiene rule so that separately run tools such as &lt;code&gt;opa check&lt;/code&gt; can read the file, but they aren't the cause of the fail-open.&lt;/p&gt;

&lt;h3&gt;
  
  
  When the Plugin Explicitly Fails Open
&lt;/h3&gt;

&lt;p&gt;The plugin's source code does have a branch that genuinely fails open. Before reading the policy file, the plugin checks it with &lt;code&gt;os.Stat&lt;/code&gt;. If the file &lt;strong&gt;doesn't exist&lt;/strong&gt;, the plugin logs this message and allows the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OPA policy file %s does not exist, failing open and allowing request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a typo in the path inside &lt;code&gt;opa-args&lt;/code&gt;, or getting the &lt;code&gt;/etc/docker&lt;/code&gt; → &lt;code&gt;/opa&lt;/code&gt; translation wrong, shuts protection off completely. In this case, the plugin log does leave a trace.&lt;/p&gt;

&lt;p&gt;The decision table derived from the source code:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Plugin decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Policy file missing&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Allow (fail-open)&lt;/strong&gt;, logged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File unreadable (e.g., permission error)&lt;/td&gt;
&lt;td&gt;Deny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rego compile or evaluation error&lt;/td&gt;
&lt;td&gt;Deny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;allow&lt;/code&gt; undefined&lt;/td&gt;
&lt;td&gt;Deny&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin not in the chain&lt;/td&gt;
&lt;td&gt;Plugin is never asked, &lt;strong&gt;allow&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Detection
&lt;/h3&gt;

&lt;p&gt;The plugin appearing "enabled" or the file hash being correct won't catch this problem. A drift check that collects the policy file's hash, the file mode, and the enabled state from &lt;code&gt;docker plugin ls&lt;/code&gt; returns a "correct" result even during a fail-open. Don't rely on configuration output such as &lt;code&gt;docker info&lt;/code&gt; for the actual state of the chain either.&lt;/p&gt;

&lt;p&gt;The reliable check is a behavioral test. With a regular user account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--privileged&lt;/span&gt; alpine &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command should be denied with this message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker: Error response from daemon: authorization denied by plugin opa-docker-authz:latest: request rejected by administrative policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It isn't enough for the command to merely fail. It can also fail because the image couldn't be pulled or the daemon couldn't be reached. Check that the output contains &lt;code&gt;authorization denied&lt;/code&gt;. A version of this test that runs on a schedule is in Section 11.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Pitfall 2: Updating the Policy
&lt;/h2&gt;

&lt;p&gt;According to its source code, the plugin reads the policy file from disk on &lt;strong&gt;every request&lt;/strong&gt;. There is no cache. As soon as the file changes, the next request is evaluated against the new policy. Updating the policy doesn't require reloading the plugin.&lt;/p&gt;

&lt;p&gt;The real risk is the disable/enable pair making its way into update automation. Automation that disables and re-enables the plugin when the policy changes, and doesn't reload because &lt;code&gt;daemon.json&lt;/code&gt; didn't change, turns authorization off on every machine it touches, each time it runs, until the next reload or restart. Even if the correct procedure is written down in the documentation, this risk goes unnoticed when the automation does something different.&lt;/p&gt;

&lt;p&gt;A safe policy update:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Compile the new policy with the OPA version the plugin uses (&lt;code&gt;opa check&lt;/code&gt;) and evaluate it against test inputs (&lt;code&gt;opa eval&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Put the file in place. Because the plugin reads the file on every request, if it sees a half-written file mid-write, that request is denied due to a compile error. To avoid this, write the new file under a temporary name in the same directory and swap it in with &lt;code&gt;mv&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Don't touch the plugin.&lt;/li&gt;
&lt;li&gt;Run the &lt;code&gt;--privileged&lt;/code&gt; test.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the plugin itself changed (version upgrade, reinstall), run &lt;code&gt;systemctl reload docker&lt;/code&gt; once you're done and repeat the behavioral test. If the policy forbids writes to the plugin API (Section 9), the plugin must first be removed from the chain via &lt;code&gt;daemon.json&lt;/code&gt; before these operations.&lt;/p&gt;

&lt;p&gt;A pattern for rolling out authorization to multiple machines: back up → reload → roll back automatically if &lt;code&gt;docker ps&lt;/code&gt; fails → &lt;code&gt;--privileged&lt;/code&gt; test. It doesn't stop running containers and can run on machines in parallel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;span class="nv"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/docker/daemon.json
&lt;span class="nv"&gt;bak&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="s2"&gt;.bak.&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$bak&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Merge authorization-plugins into the existing settings&lt;/span&gt;
jq &lt;span class="s1"&gt;'. + {"authorization-plugins": ["opa-docker-authz"]}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$bak&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="s2"&gt;.new"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="s2"&gt;.new"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

systemctl reload docker

&lt;span class="c"&gt;# Roll back automatically if the daemon is unhealthy&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; docker ps &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$bak&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cfg&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  systemctl reload docker
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ROLLBACK"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"ps=OK "&lt;/span&gt;

&lt;span class="c"&gt;# Behavioral test&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--privileged&lt;/span&gt; alpine &lt;span class="nb"&gt;true &lt;/span&gt;2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"authorization denied"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"priv=DENIED"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"priv=OPEN"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;2
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't consider the rollout done until every machine reports &lt;code&gt;ps=OK priv=DENIED&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Pitfall 3: Rego Syntax and Plugin Version
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Version Status
&lt;/h3&gt;

&lt;p&gt;Rego syntax changed with OPA 1.0. &lt;code&gt;if&lt;/code&gt; before rule bodies and &lt;code&gt;contains&lt;/code&gt; for multi-value rules are now mandatory. The policy in Section 5 is written in classic (0.x) syntax.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;opa-docker-authz&lt;/code&gt; versions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;OPA&lt;/th&gt;
&lt;th&gt;Managed plugin on Docker Hub&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v0.9&lt;/td&gt;
&lt;td&gt;0.60.0&lt;/td&gt;
&lt;td&gt;Yes (latest tag)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v0.10&lt;/td&gt;
&lt;td&gt;1.3.0&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v0.11&lt;/td&gt;
&lt;td&gt;1.21.1&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The project is maintained, and new releases are published on GitHub. However, the newest tag you can pull from Docker Hub with &lt;code&gt;docker plugin install&lt;/code&gt; is 0.9. Because 0.10 and later aren't published on Docker Hub as managed plugins, using these versions requires building the plugin from source.&lt;/p&gt;

&lt;p&gt;The result: a classic policy written for 0.9 won't compile when the plugin is upgraded to 0.10+. &lt;code&gt;opa check&lt;/code&gt; with OPA 1.21.1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rego_parse_error: `if` keyword is required before rule body
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin denies the request on a compile error. The result is fail-closed, but every request, including &lt;code&gt;docker ps&lt;/code&gt;, is denied and Docker becomes unusable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bridge: &lt;code&gt;import rego.v1&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;For a policy that works on both versions, use &lt;code&gt;import rego.v1&lt;/code&gt;. This import was added in OPA 0.59.0. Once the policy is converted to OPA 1.x syntax and this line is added at the top, both 0.60 and 1.x compile it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="ow"&gt;package&lt;/span&gt; &lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;authz&lt;/span&gt;

&lt;span class="ow"&gt;import&lt;/span&gt; &lt;span class="n"&gt;rego&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v1&lt;/span&gt;

&lt;span class="ow"&gt;default&lt;/span&gt; &lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;is_container_create&lt;/span&gt;
    &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;deny&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Privileged&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;authz-hardened-v1.rego&lt;/code&gt; is in this form and was evaluated with all test inputs on OPA 0.60.0 and 1.21.1. Both versions return the same decisions. Converting the policy to this form before upgrading the plugin is recommended.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Rego Pitfall: An Error Can Turn into "Allow"
&lt;/h3&gt;

&lt;p&gt;By default, OPA treats a runtime error in a built-in function (for example, an invalid regex) not as an error but as &lt;strong&gt;undefined&lt;/strong&gt;. The plugin also calls OPA without the &lt;code&gt;StrictBuiltinErrors&lt;/code&gt; option.&lt;/p&gt;

&lt;p&gt;If a built-in errors inside a &lt;code&gt;deny&lt;/code&gt; rule, the rule becomes undefined, meaning it doesn't fire. &lt;code&gt;not deny&lt;/code&gt; is then true. In the deny-list + &lt;code&gt;not deny&lt;/code&gt; pattern, an error means &lt;strong&gt;allow&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Test: with a &lt;code&gt;deny&lt;/code&gt; rule containing a broken regex, a privileged input returns &lt;code&gt;allow = true&lt;/code&gt;. Running the same evaluation with &lt;code&gt;--strict-builtin-errors&lt;/code&gt; returns &lt;code&gt;eval_builtin_error&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Mitigations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run policy tests in CI (continuous integration) with &lt;code&gt;opa eval --strict-builtin-errors&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In &lt;code&gt;deny&lt;/code&gt; rules, avoid built-ins that can error (regex, parsing functions) as much as possible.&lt;/li&gt;
&lt;li&gt;Where possible, use an allow-list instead of a deny-list. Describe what's allowed, not what's forbidden. That way, an undefined result means deny.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9. Pitfall 4: What the Policy Can't See
&lt;/h2&gt;

&lt;p&gt;The policy in Section 5 and its hardened version were tested against 19 test scenarios, both with &lt;code&gt;opa eval&lt;/code&gt; and on a live Docker daemon.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test input&lt;/th&gt;
&lt;th&gt;Original&lt;/th&gt;
&lt;th&gt;Hardened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Own workspace&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Another user's workspace&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--privileged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/etc&lt;/code&gt; bind&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workspace subpath&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Root directory via &lt;code&gt;--mount type=bind&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Volume mount with driver options&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--volumes-from&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--gpus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bodyless create request&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin disable request&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin list (GET)&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;volume create&lt;/code&gt; with driver options&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain &lt;code&gt;volume create&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bodyless &lt;code&gt;volume create&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exec --privileged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path containing &lt;code&gt;..&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--cap-add&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;td&gt;denied&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Live daemon tests were run with plugin v0.9 (OPA 0.60). In the &lt;code&gt;opa eval&lt;/code&gt; evaluation, the hardened column is the same for the classic-syntax &lt;code&gt;authz-hardened.rego&lt;/code&gt; on OPA 0.60, and for &lt;code&gt;authz-hardened-v1.rego&lt;/code&gt; on both OPA 0.60 and 1.21.1. The rows for another user's workspace and &lt;code&gt;exec&lt;/code&gt; are the subject of Section 10.&lt;/p&gt;

&lt;p&gt;There's an inconsistency in the plain &lt;code&gt;volume create&lt;/code&gt; row. When a named volume is attached with &lt;code&gt;-v name:/path&lt;/code&gt;, it lands in &lt;code&gt;HostConfig.Binds&lt;/code&gt;, doesn't match the &lt;code&gt;/home/...&lt;/code&gt; format, hits the bind rule, and is denied. When the same volume is attached with &lt;code&gt;--mount type=volume&lt;/code&gt;, it has no driver configuration, so it's allowed. Plain volumes don't point to a host directory, so this is harmless. A policy that also wants to allow &lt;code&gt;-v&lt;/code&gt; should add a separate branch to &lt;code&gt;bind_ok&lt;/code&gt; for named volumes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Volume-Type Mounts
&lt;/h3&gt;

&lt;p&gt;The original policy checks &lt;code&gt;HostConfig.Mounts&lt;/code&gt; entries only if &lt;code&gt;Type == "bind"&lt;/code&gt;. Docker's &lt;code&gt;local&lt;/code&gt; volume driver, however, supports creating a volume that points to a directory on the host through driver options. Such a mount arrives as &lt;code&gt;Type == "volume"&lt;/code&gt; and isn't covered by the bind rule. The container can access an arbitrary directory on the host.&lt;/p&gt;

&lt;p&gt;The fix: deny volume mounts that contain a driver configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"volume"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VolumeOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DriverConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;volume create&lt;/code&gt; Options
&lt;/h3&gt;

&lt;p&gt;You can reach the same result by first creating a volume with driver options through a separate &lt;code&gt;POST /volumes/create&lt;/code&gt; request and then mounting it. Because the original policy allows every endpoint other than container create, this request isn't checked.&lt;/p&gt;

&lt;p&gt;The fix: make volume creation a separate decision point and accept only requests without driver options. Volumes without options remain allowed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;is_volume_create&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;volume_ok&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;is_volume_create&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;
    &lt;span class="n"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"?"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;"/volumes/create"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;volume_ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;is_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"DriverOpts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is intentionally written as an allow-list. A deny-list rule such as &lt;code&gt;volume_deny { count(input.Body.DriverOpts) &amp;gt; 0 }&lt;/code&gt; doesn't fire when the body doesn't arrive, and the request goes through (see Bodyless Requests below). The &lt;code&gt;is_object&lt;/code&gt; check also denies the cases where the body doesn't arrive at all or arrives as &lt;code&gt;null&lt;/code&gt;. The Docker CLI always sends a body with &lt;code&gt;volume create&lt;/code&gt; requests, and &lt;code&gt;DriverOpts&lt;/code&gt; is an empty object when there are no options, so normal usage isn't affected.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--volumes-from&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;HostConfig.VolumesFrom&lt;/code&gt; lets a container inherit all the mounts of another container. On a shared machine, this means inheriting another user's workspace from a container that has it mounted. The original policy doesn't look at this field.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VolumesFrom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Path Normalization
&lt;/h3&gt;

&lt;p&gt;The policy only splits the path on &lt;code&gt;/&lt;/code&gt;; it doesn't resolve it. &lt;code&gt;/home/../workspace&lt;/code&gt; splits into four parts and satisfies the rule. This example doesn't directly give access to sensitive data, but it shows that the policy evaluates the path as text. The hardened version rejects an empty value, &lt;code&gt;.&lt;/code&gt;, and &lt;code&gt;..&lt;/code&gt; in the username segment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;bind_ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;":"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"/workspace"&lt;/span&gt;
    &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;
    &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"home"&lt;/span&gt;
    &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;".."&lt;/span&gt;&lt;span class="p"&gt;}[&lt;/span&gt;&lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"workspace"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin's input has a &lt;code&gt;Resolved&lt;/code&gt; field that is expected to carry the path with symlinks resolved. In the managed plugin, however, this field arrives empty. The plugin does the resolution on its own file system, and the host's &lt;code&gt;/home&lt;/code&gt; directory doesn't exist inside the plugin. For this reason, &lt;code&gt;Resolved&lt;/code&gt; can't be used as a symlink defense.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bodyless Requests
&lt;/h3&gt;

&lt;p&gt;This is the most insidious blind spot of the deny-list pattern. All the &lt;code&gt;deny&lt;/code&gt; rules look at fields under &lt;code&gt;input.Body.HostConfig&lt;/code&gt;. If the body doesn't reach the plugin, or the plugin can't parse it, no &lt;code&gt;deny&lt;/code&gt; rule fires, &lt;code&gt;not deny&lt;/code&gt; is true, and the request goes through.&lt;/p&gt;

&lt;p&gt;Don't assume the plugin will always parse the body. Docker's documentation also states that the body isn't forwarded to the plugin in every case. The fix is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;deny&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HostConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This rule denies a container create request that has no &lt;code&gt;HostConfig&lt;/code&gt;. A normal &lt;code&gt;docker run&lt;/code&gt; always sends &lt;code&gt;HostConfig&lt;/code&gt;, so the rule doesn't affect legitimate use.&lt;/p&gt;

&lt;p&gt;This blind spot isn't theoretical. In CVE-2024-41110 (CVE: Common Vulnerabilities and Exposures), a specially crafted API request could make the daemon forward the request to the authorization plugin without its body. Plugins that make decisions based on the body could allow a request they would normally deny. The vulnerability was first fixed in January 2019, in Docker Engine v18.09.1. Because the fix wasn't carried forward to later major versions, it came back as a regression. According to the advisory, the affected versions are v19.03.15, v20.10.27, v23.0.14, v24.0.9, v25.0.5, v26.0.2, v26.1.4, v27.0.3, v27.1.0, and earlier versions on each of those branches. The advisory gives patched versions for only three branches: versions after v23.0.14, v26.1.4, and v27.1.0. For docker-ce, the fix is in v27.1.1. The advisory gives no patched version for the 19.03, 20.10, 24.0, 25.0, 26.0, and 27.0 branches. If you're on one of these branches, upgrade to a patched version.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;deny { not input.Body.HostConfig }&lt;/code&gt; rule catches exactly this case: in a bodyless create request, &lt;code&gt;HostConfig&lt;/code&gt; is undefined and the request is denied. Even if the engine is patched, the rule should stay as defense in depth, because the policy's security shouldn't depend on a single fix in the engine.&lt;/p&gt;

&lt;p&gt;The same blind spot exists in every deny-list rule that looks at the body. When you add a decision point other than container create to the policy (&lt;code&gt;volume create&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;), test separately that it also denies bodyless requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Plugin API
&lt;/h3&gt;

&lt;p&gt;In the journal entry from Section 6, the request to disable the plugin passes through authorization and is allowed. This means any user who can reach the socket can turn off authorization entirely. It's a direct consequence of the mechanism in Section 6.&lt;/p&gt;

&lt;p&gt;The fix: deny every operation on the plugin API other than reads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_container_create&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_volume_create&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_plugin_write&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;is_plugin_write&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PathArr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"plugins"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;is_plugin_write&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PathArr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s2"&gt;"v"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PathArr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"plugins"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second rule catches version-prefixed paths (&lt;code&gt;/v1.47/plugins/...&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The operational consequence: this rule also prevents administrators from disabling the plugin over the socket. To upgrade or reinstall the plugin, first remove the &lt;code&gt;authorization-plugins&lt;/code&gt; key from &lt;code&gt;daemon.json&lt;/code&gt; and reload. When you're done, put the key back and reload again. This trade-off also makes the dangerous disable/enable habit from Section 7 impossible.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;exec --privileged&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;docker exec --privileged&lt;/code&gt; starts a privileged process in a running container. This request goes to the &lt;code&gt;POST /containers/{id}/exec&lt;/code&gt; endpoint, and its body contains a &lt;code&gt;Privileged: true&lt;/code&gt; field. Because the policy in Section 5 only checks container creation, this request is unrestricted. So privileged access that was denied at the &lt;code&gt;create&lt;/code&gt; stage can be obtained at the &lt;code&gt;exec&lt;/code&gt; stage.&lt;/p&gt;

&lt;p&gt;This part doesn't require identity, so policy can close it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rego"&gt;&lt;code&gt;&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_container_create&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_volume_create&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_plugin_write&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;is_exec_create&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;allow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;is_exec_create&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;exec_ok&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;is_exec_create&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"POST"&lt;/span&gt;
    &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PathArr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PathArr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"exec"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;exec_ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Privileged&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line replaces the general &lt;code&gt;allow&lt;/code&gt; rule from The Plugin API.&lt;/p&gt;

&lt;p&gt;The rule is intentionally written as an allow-list. The Docker CLI always sends the &lt;code&gt;Privileged&lt;/code&gt; field in exec requests, so a normal &lt;code&gt;docker exec&lt;/code&gt; keeps working. Because it's an allow-list, a bodyless request or one missing the field is denied.&lt;/p&gt;

&lt;p&gt;A deny-list rule such as &lt;code&gt;exec_deny { not input.Body }&lt;/code&gt; doesn't do the job. When the plugin can't parse the body, the &lt;code&gt;input.Body&lt;/code&gt; field can arrive as &lt;code&gt;null&lt;/code&gt;. &lt;code&gt;null&lt;/code&gt; is a defined value in Rego, not &lt;code&gt;false&lt;/code&gt;, so &lt;code&gt;not input.Body&lt;/code&gt; is false. A bodyless privileged exec request passes such a rule. The create-side &lt;code&gt;deny { not input.Body.HostConfig }&lt;/code&gt; rule isn't affected by this problem: the &lt;code&gt;HostConfig&lt;/code&gt; field of a &lt;code&gt;null&lt;/code&gt; body is undefined, so the rule fires.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--gpus&lt;/code&gt; and &lt;code&gt;DeviceRequests&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--gpus&lt;/code&gt; writes to &lt;code&gt;HostConfig.DeviceRequests&lt;/code&gt;, not &lt;code&gt;HostConfig.Devices&lt;/code&gt;. The &lt;code&gt;Devices&lt;/code&gt; rule doesn't block GPU access. In this scenario, GPU access is part of the goal, so the field was intentionally left open. A policy that wants to restrict GPUs has to handle &lt;code&gt;DeviceRequests&lt;/code&gt; separately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Other Unchecked Fields
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;CgroupnsMode&lt;/code&gt;, &lt;code&gt;CgroupParent&lt;/code&gt;, &lt;code&gt;Sysctls&lt;/code&gt;, &lt;code&gt;Runtime&lt;/code&gt;, and &lt;code&gt;PortBindings&lt;/code&gt; aren't checked by either policy. They were considered lower risk. By the nature of the deny-list approach, the list has to be reviewed whenever a new field is added to the Docker API.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. What an Authorization Plugin Can't Provide
&lt;/h2&gt;

&lt;p&gt;Two rows in the hardened table are still open: another user's workspace and &lt;code&gt;exec&lt;/code&gt;. &lt;code&gt;exec --privileged&lt;/code&gt; can be closed with policy (Section 9). But restrictions that depend on the question "whose container is this?" can't be closed by improving the policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  No Identity
&lt;/h3&gt;

&lt;p&gt;For requests over the Unix socket, the &lt;code&gt;User&lt;/code&gt; field is empty (Section 3). The policy can check the &lt;strong&gt;format&lt;/strong&gt; of the &lt;code&gt;/home/&amp;lt;user&amp;gt;/workspace&lt;/code&gt; path, but it can't know whether the requester is that &lt;code&gt;&amp;lt;user&amp;gt;&lt;/code&gt;. A user can bind another user's workspace into their own container.&lt;/p&gt;

&lt;p&gt;Home directories with 0700 permissions don't stop this. The files are accessed by the daemon, which runs as root. For directories on local disk, this means direct read and write access. If workspaces are mounted over NFS (Network File System) with &lt;code&gt;sec=krb5&lt;/code&gt;, whether the root daemon can access another user's data depends on Kerberos identity mapping.&lt;/p&gt;

&lt;p&gt;The same cause creates one more risk. Because users own their home directories, a user can delete the workspace directory and replace it with a symlink that points somewhere else on the host. The path format passes the policy, and because Docker resolves symlinks in the bind source (Section 5), whatever the symlink points to gets mounted. If the workspace is an NFS mount point, it can't be deleted. When the mount is absent, or the workspace is a local directory, this is possible. A policy that looks at path format can't close this risk.&lt;/p&gt;

&lt;p&gt;Carrying identity with TLS client certificates is theoretically possible. However, it requires users to connect over TLS with their own certificates instead of the local socket. For a shared workstation, that's a separate infrastructure project.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;exec&lt;/code&gt;, &lt;code&gt;commit&lt;/code&gt;, and &lt;code&gt;rm&lt;/code&gt; Are Unrestricted
&lt;/h3&gt;

&lt;p&gt;The policy only checks container creation. And since there's no identity, you can't write a rule based on "whose container is this?" either. A user can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;exec&lt;/code&gt; into another user's container as root,&lt;/li&gt;
&lt;li&gt;turn that container into an image with &lt;code&gt;commit&lt;/code&gt; and take it out,&lt;/li&gt;
&lt;li&gt;stop or delete it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can ban &lt;code&gt;exec&lt;/code&gt; entirely, but then users can't get into their own containers either. Without ownership information, you can't write a selective rule.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;docker&lt;/code&gt; Group Is Root-Equivalent
&lt;/h3&gt;

&lt;p&gt;According to Docker's official documentation, the &lt;code&gt;docker&lt;/code&gt; group grants a user root-level privileges, and only trusted users should be able to control the Docker daemon. An authorization plugin doesn't change this; it only closes some paths. The list of those paths can easily end up incomplete. The plugin can silently drop out of service through an administrator mistake or, if the policy isn't strict enough, through a regular user's request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authorization Alone Is Not a Security Boundary
&lt;/h3&gt;

&lt;p&gt;It helps to split the risks into two categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gaps by design:&lt;/strong&gt; Access to the host through legitimate Docker features (such as &lt;code&gt;--privileged&lt;/code&gt;, host bind mounts, and volume driver options). An authorization policy exists to close these.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime escape vulnerabilities:&lt;/strong&gt; Escaping a container through security bugs in runc or the kernel. No policy closes these. The remedy is keeping runc, Docker, and the kernel up to date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An authorization plugin is a useful layer for the first category. But because it has no identity, can be dropped from the chain, and relies on a deny-list that can be incomplete, it isn't enough on its own against untrusted users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alternatives
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rootless Docker:&lt;/strong&gt; The daemon runs with the user's own privileges. The root-equivalence problem largely goes away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Podman:&lt;/strong&gt; A daemonless alternative that can run rootless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;userns-remap:&lt;/strong&gt; Maps root in the container to an unprivileged UID range on the host.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate daemon per user:&lt;/strong&gt; Each user gets their own socket and isolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes + admission control:&lt;/strong&gt; Identity and authorization are built into the API server. The policy (for example, with OPA Gatekeeper) is written in a context that knows identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A root-owned launcher:&lt;/strong&gt; Instead of the Docker socket, users get a small root-owned tool that launches containers only with allowed parameters. Identity comes from the operating system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every option has a cost. Rootless Docker can cause compatibility problems when added to an existing setup after the fact. Because userns-remap maps root in the container to a different UID on the host, it can break users' access to their NFS data under their own identities. GPU access can also be affected. For requirements that need identity, a root-owned launcher or a separate daemon per user is a sturdier foundation than an authorization plugin.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Production Checklist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Version&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run Docker Engine 29.3.1 or later. This version patches CVE-2026-34040, which allows AuthZ plugins to be bypassed, and CVE-2026-33997, which allows privilege validation to be skipped during &lt;code&gt;docker plugin install&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If you have to stay on an older version, at least use one that is patched for CVE-2024-41110. The advisory gives patched versions only for the 23.0, 26.1, and 27.1 branches: versions after v23.0.14, v26.1.4, and v27.1.0 (docker-ce v27.1.1). No patched version is given for the 19.03, 20.10, 24.0, 25.0, 26.0, and 27.0 branches; if you're on one of them, upgrade to a patched version (Section 9).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Permissions&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set the policy directory to &lt;code&gt;0755 root:root&lt;/code&gt; and the file to &lt;code&gt;0644 root:root&lt;/code&gt;. This isn't the cause of the fail-open. It's a recommended hygiene rule so that separately run tools such as &lt;code&gt;opa check&lt;/code&gt; can read the file.&lt;/li&gt;
&lt;li&gt;Write the policy path as the path inside the plugin (&lt;code&gt;/etc/docker/...&lt;/code&gt; → &lt;code&gt;/opa/...&lt;/code&gt;). If the path is wrong, the plugin explicitly fails open.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Behavioral Test&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After every installation, every policy change, and every plugin operation, run &lt;code&gt;docker run --rm --privileged alpine true&lt;/code&gt; as a regular user.&lt;/li&gt;
&lt;li&gt;Check for the &lt;code&gt;authorization denied&lt;/code&gt; message, not just the exit code.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker plugin ls&lt;/code&gt;, file hashes, and file modes don't show the state of the chain. A drift check limited to these won't catch a fail-open.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Periodic Test and Alerting&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run the behavioral test at regular intervals and raise an alert if it isn't denied. This is the check that catches the situation in Section 6 the earliest:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Run as a non-root service account in the docker group.&lt;/span&gt;
&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--privileged&lt;/span&gt; alpine &lt;span class="nb"&gt;true &lt;/span&gt;2&amp;gt;&amp;amp;1&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"authorization denied"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;logger &lt;span class="nt"&gt;-p&lt;/span&gt; auth.crit &lt;span class="s2"&gt;"docker-authz: privileged test NOT denied: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;succeeded&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Update Procedure&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For policy changes, don't touch the plugin. Change the file and test.&lt;/li&gt;
&lt;li&gt;Don't use &lt;code&gt;docker plugin disable/enable&lt;/code&gt; to update the policy.&lt;/li&gt;
&lt;li&gt;Before plugin operations (upgrade, reinstall), remove the plugin from &lt;code&gt;daemon.json&lt;/code&gt; and reload. When you're done, add it back, reload again, and repeat the behavioral test.&lt;/li&gt;
&lt;li&gt;Compare your automation scripts against the documented procedure.&lt;/li&gt;
&lt;li&gt;Deny write operations on the plugin API in the policy.&lt;/li&gt;
&lt;li&gt;Before upgrading the plugin version, convert the policy to the &lt;code&gt;import rego.v1&lt;/code&gt; form and run &lt;code&gt;opa check&lt;/code&gt; with the target OPA version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Policy&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deny bodyless requests: &lt;code&gt;deny { not input.Body.HostConfig }&lt;/code&gt;. For decision points other than container create (&lt;code&gt;volume create&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;), use an allow-list and test with a bodyless request.&lt;/li&gt;
&lt;li&gt;Check the volume type in &lt;code&gt;Mounts&lt;/code&gt;, &lt;code&gt;volume create&lt;/code&gt; options, and &lt;code&gt;VolumesFrom&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Reject &lt;code&gt;.&lt;/code&gt;, &lt;code&gt;..&lt;/code&gt;, and empty values in path segments.&lt;/li&gt;
&lt;li&gt;Test in CI with &lt;code&gt;--strict-builtin-errors&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Backup&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't exclude the &lt;code&gt;/var/lib/docker&lt;/code&gt; directory from backups, or explicitly add the plugin to your restore procedure. The plugin binary lives there. If &lt;code&gt;daemon.json&lt;/code&gt; requires the plugin and the plugin is missing, &lt;code&gt;validateAuthzPlugins&lt;/code&gt; fails and dockerd doesn't start. The result is fail-closed: authorization doesn't open up, but Docker stops entirely on that machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Logging&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ship the plugin's decision logs to a central location. An allow decision on the plugin API, followed by decision logs that stop, is the first sign of the plugin being dropped from the chain.&lt;/li&gt;
&lt;li&gt;Define a separate alert rule for the &lt;code&gt;failing open and allowing request&lt;/code&gt; message.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  12. Conclusion
&lt;/h2&gt;

&lt;p&gt;You know a security control works by testing whether it actually blocks, not because it looks "enabled." The plugin can look enabled, and the policy file and permissions can be correct. Despite all that, the authorization plugin chain can be empty. A one-line &lt;code&gt;docker run --privileged&lt;/code&gt; test reveals this in a second. &lt;code&gt;docker plugin disable&lt;/code&gt; turns authorization off, &lt;code&gt;enable&lt;/code&gt; doesn't turn it back on, and &lt;code&gt;systemctl reload docker&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;A Docker authorization plugin doesn't change the fact that the &lt;code&gt;docker&lt;/code&gt; group is root-equivalent. It's a layer that doesn't know identity, checks only some endpoints, and can silently drop out of service. It's useful for preventing accidents among trusted users. Separating untrusted users requires a sturdier foundation, such as rootless operation, per-user isolation, or a control plane that knows identity.&lt;/p&gt;

&lt;p&gt;Since that incident, I run the behavioral test periodically. You can't know the difference between "looks enabled" and "works" without testing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker, Access authorization plugin: &lt;a href="https://docs.docker.com/engine/extend/plugins_authorization/" rel="noopener noreferrer"&gt;https://docs.docker.com/engine/extend/plugins_authorization/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker, Linux post-installation steps (docker group): &lt;a href="https://docs.docker.com/engine/install/linux-postinstall/" rel="noopener noreferrer"&gt;https://docs.docker.com/engine/install/linux-postinstall/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker, Docker daemon attack surface: &lt;a href="https://docs.docker.com/engine/security/#docker-daemon-attack-surface" rel="noopener noreferrer"&gt;https://docs.docker.com/engine/security/#docker-daemon-attack-surface&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker, dockerd configuration reload behavior: &lt;a href="https://docs.docker.com/reference/cli/dockerd/#configuration-reload-behavior" rel="noopener noreferrer"&gt;https://docs.docker.com/reference/cli/dockerd/#configuration-reload-behavior&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;moby, &lt;code&gt;pkg/authorization/middleware.go&lt;/code&gt;: &lt;a href="https://github.com/moby/moby/blob/b1bf272a8c767e2a21737fa0625844e9970c3dc2/pkg/authorization/middleware.go" rel="noopener noreferrer"&gt;https://github.com/moby/moby/blob/b1bf272a8c767e2a21737fa0625844e9970c3dc2/pkg/authorization/middleware.go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;moby, &lt;code&gt;daemon/pkg/plugin/backend_linux.go&lt;/code&gt;: &lt;a href="https://github.com/moby/moby/blob/b1bf272a8c767e2a21737fa0625844e9970c3dc2/daemon/pkg/plugin/backend_linux.go" rel="noopener noreferrer"&gt;https://github.com/moby/moby/blob/b1bf272a8c767e2a21737fa0625844e9970c3dc2/daemon/pkg/plugin/backend_linux.go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;moby, &lt;code&gt;daemon/command/daemon.go&lt;/code&gt;: &lt;a href="https://github.com/moby/moby/blob/b1bf272a8c767e2a21737fa0625844e9970c3dc2/daemon/command/daemon.go" rel="noopener noreferrer"&gt;https://github.com/moby/moby/blob/b1bf272a8c767e2a21737fa0625844e9970c3dc2/daemon/command/daemon.go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;moby, issue #31836 (a disabled authz plugin blocking requests): &lt;a href="https://github.com/moby/moby/issues/31836" rel="noopener noreferrer"&gt;https://github.com/moby/moby/issues/31836&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;moby, commit &lt;code&gt;38de272bd4&lt;/code&gt; (removing a disabled authz plugin from the chain): &lt;a href="https://github.com/moby/moby/commit/38de272bd4" rel="noopener noreferrer"&gt;https://github.com/moby/moby/commit/38de272bd4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;moby, PR #33655 (Eliminate authz plugin disable race): &lt;a href="https://github.com/moby/moby/pull/33655" rel="noopener noreferrer"&gt;https://github.com/moby/moby/pull/33655&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NVD, CVE-2024-41110: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2024-41110" rel="noopener noreferrer"&gt;https://nvd.nist.gov/vuln/detail/CVE-2024-41110&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;moby, GHSA-v23v-6jw2-98fq (CVE-2024-41110 advisory): &lt;a href="https://github.com/moby/moby/security/advisories/GHSA-v23v-6jw2-98fq" rel="noopener noreferrer"&gt;https://github.com/moby/moby/security/advisories/GHSA-v23v-6jw2-98fq&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NVD, CVE-2026-34040: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-34040" rel="noopener noreferrer"&gt;https://nvd.nist.gov/vuln/detail/CVE-2026-34040&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NVD, CVE-2026-33997: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-33997" rel="noopener noreferrer"&gt;https://nvd.nist.gov/vuln/detail/CVE-2026-33997&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;opa-docker-authz, &lt;code&gt;main.go&lt;/code&gt;: &lt;a href="https://github.com/open-policy-agent/opa-docker-authz/blob/v0.9/main.go" rel="noopener noreferrer"&gt;https://github.com/open-policy-agent/opa-docker-authz/blob/v0.9/main.go&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;opa-docker-authz, releases: &lt;a href="https://github.com/open-policy-agent/opa-docker-authz/releases" rel="noopener noreferrer"&gt;https://github.com/open-policy-agent/opa-docker-authz/releases&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;opa-docker-authz v0.9, &lt;code&gt;go.mod&lt;/code&gt;: &lt;a href="https://github.com/open-policy-agent/opa-docker-authz/blob/v0.9/go.mod" rel="noopener noreferrer"&gt;https://github.com/open-policy-agent/opa-docker-authz/blob/v0.9/go.mod&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docker Hub, &lt;code&gt;openpolicyagent/opa-docker-authz-v2&lt;/code&gt; tags: &lt;a href="https://hub.docker.com/r/openpolicyagent/opa-docker-authz-v2/tags" rel="noopener noreferrer"&gt;https://hub.docker.com/r/openpolicyagent/opa-docker-authz-v2/tags&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OPA, v0 → v1 upgrade guide: &lt;a href="https://www.openpolicyagent.org/docs/v0-upgrade" rel="noopener noreferrer"&gt;https://www.openpolicyagent.org/docs/v0-upgrade&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OPA, v0.59.0 release notes (&lt;code&gt;rego.v1&lt;/code&gt; import): &lt;a href="https://github.com/open-policy-agent/opa/releases/tag/v0.59.0" rel="noopener noreferrer"&gt;https://github.com/open-policy-agent/opa/releases/tag/v0.59.0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OPA, errors and strict built-in errors: &lt;a href="https://www.openpolicyagent.org/docs/errors" rel="noopener noreferrer"&gt;https://www.openpolicyagent.org/docs/errors&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>devops</category>
      <category>opa</category>
    </item>
  </channel>
</rss>
