OdaiGH/Pam-accessctl
A lightweight PAM-based SSH access control tool. Define who can log into which server using a simple YAML policy — enforced at login time via a custom PAM module, no agents, no daemons, no network calls.
accessctl
accessctl enforces SSH access control through PAM using a YAML policy file.
It ships a fast policy-check binary (accessctl-check) called on every login and an admin CLI (accessctl) for managing policies.
Prerequisites
The C PAM module (pam_accessctl/) requires the PAM development headers.
Install them before running make — pip install . does NOT build the C module.
Debian / Ubuntu
sudo apt install libpam-devRHEL / CentOS / Fedora
sudo yum install pam-devel
# or
sudo dnf install pam-develArch Linux
sudo pacman -S linux-pamInstall
pip install .
# or
python setup.py installThis installs two executables:
| Binary | Purpose |
|---|---|
accessctl |
Admin CLI — validate, apply, check, audit |
accessctl-check |
PAM check binary — called on every login |
The post-install hook creates /etc/accessctl/ and writes /etc/accessctl/server-id
from socket.gethostname() if it does not already exist.
Build and install the C PAM module (optional but recommended)
cd pam_accessctl/
make
sudo make installThis compiles pam_accessctl.so and installs it to /lib/security/pam_accessctl.so.
Setup
1. Confirm server identity
cat /etc/accessctl/server-idEdit it if the auto-detected hostname is wrong. The file must contain exactly one
line: the server name as it appears in policy.yaml.
2. Write your first policy
# /etc/accessctl/policy.yaml
version: 1
servers:
prod-web-01:
allow:
- user: alice
- group: sre
deny:
- user: intern
time_window: "07:00-22:00"
source_ip:
- "10.0.0.0/8"
- "192.168.0.0/16"
staging:
allow:
- group: developers
time_window: "08:00-20:00"3. Apply the policy
sudo accessctl apply /etc/accessctl/policy.yamlapply validates the YAML, copies it atomically to /etc/accessctl/policy.yaml,
and compiles a JSON cache at /etc/accessctl/policy.json that accessctl-check
reads at login time (no YAML parsing on the hot path).
PAM Configuration
Option A — custom PAM module (recommended)
Add to /etc/pam.d/sshd before the default auth stack:
auth required /lib/security/pam_accessctl.so
Option B — pam_exec.so (no C compilation required)
auth required pam_exec.so /usr/local/bin/accessctl-check
pam_exec.so automatically exports PAM_USER, PAM_RHOST, and PAM_SERVICE
into the environment before executing the binary.
Full /etc/pam.d/sshd example (Option B)
#%PAM-1.0
auth required pam_exec.so /usr/local/bin/accessctl-check
auth include common-auth
account include common-account
password include common-password
session optional pam_keyinit.so force revoke
session include common-session
Ordering matters. Place the
accessctlline first so it runs before
password or key authentication — this lets you block users outright regardless
of their credentials.
Also enable UsePAM yes
in /etc/ssh/sshd_config, then reload:
sudo systemctl reload sshdCLI Usage
Validate the current policy
accessctl validate
# policy is valid.
# On error:
# policy validation failed:
# - server 'prod-web-01': invalid CIDR '999.0.0.0/8' in source_ipApply a new policy (requires root)
sudo accessctl apply /path/to/new-policy.yaml
# policy applied: /etc/accessctl/policy.yaml
# compiled cache: /etc/accessctl/policy.jsonDry-run check for a user
accessctl check alice
# ALLOW: user in allow list
accessctl check intern
# DENY: user in deny list
accessctl check alice --source-ip 8.8.8.8
# DENY: ip not in allowlistExit code is 0 for ALLOW, 1 for DENY — useful in scripts:
accessctl check "$USER" && echo "would be granted access"Audit current server
accessctl audit
# server: prod-web-01
# time_window: 07:00-22:00
# source_ip: 10.0.0.0/8, 192.168.0.0/16
#
# VERDICT KIND NAME
# --------------------------------
# ALLOW user alice
# ALLOW group sre
# DENY user internShow current server identity
accessctl whoami
# prod-web-01Policy Reference
version: 1 # required, must be 1
servers:
<server-id>: # matches /etc/accessctl/server-id exactly
allow: # ordered list; first match wins
- user: <name>
- group: <name>
deny: # evaluated before allow; first match wins
- user: <name>
- group: <name>
time_window: "HH:MM-HH:MM" # optional; overnight ranges supported
source_ip: # optional; CIDR list
- "10.0.0.0/8"Evaluation order (any failure immediately denies):
- Is the server in the policy? → fail closed if not
- Is the user in the deny list? → deny
- Is the current time inside
time_window? → deny if outside - Is
PAM_RHOSTinsource_ip? → deny if not - Is the user in the allow list? → allow if matched, deny otherwise
Fail-Closed Behaviour
accessctl-check always exits 1 (DENY) when:
/etc/accessctl/server-idis missing or unreadable/etc/accessctl/policy.jsonis missing, unreadable, or contains invalid JSON- The server-id is not in the policy
PAM_USERis empty or unset- Any unexpected exception occurs
There is no "fail open" path. All failures are logged to syslog
(/dev/log → journald / rsyslog) under the identity accessctl-check[PID].
accessctl-check produces zero stdout or stderr output — PAM sees only
the exit code.
Benchmarking
Measure cold-start latency of the check binary:
time accessctl-check
# real 0m0.045s ← target: under 80 ms
# Or with PAM environment variables set:
PAM_USER=alice PAM_RHOST=10.0.1.5 PAM_SERVICE=sshd time accessctl-checkThe binary reads policy.json (pre-compiled JSON, not YAML) so the hot path
uses only Python stdlib — no PyYAML, no click, no C extensions.
To understand where time is spent:
python -X importtime -c "import accessctl.check" 2>&1 | head -20Architecture
SSH login
│
└─ PAM (pam_accessctl.so OR pam_exec.so)
│
└─ fork + execve /usr/local/bin/accessctl-check
│
├─ read /etc/accessctl/server-id (os.open)
├─ read /etc/accessctl/policy.json (os.open + json.loads)
├─ resolve groups (grp.getgrall — once)
├─ evaluate deny → time → ip → allow
└─ exit 0 (ALLOW) or exit 1 (DENY)
│
└─ PAM_SUCCESS or PAM_AUTH_ERR
accessctl-check is a standalone Python entry point. It imports only stdlib.
The accessctl apply command compiles policy.yaml → policy.json once,
so the login path never touches PyYAML.
Contributing
- Fork the repo and create a feature branch.
- Write tests in
tests/covering new behaviour. - Run the test suite:
pytest - Keep
check.pyunder 150 lines and stdlib-only. - All writes to
/etc/accessctl/must be atomic (write.tmp, thenos.rename). - Open a pull request — CI runs pytest on Python 3.9, 3.10, 3.11, 3.12.