GitHunt
OD

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 makepip install . does NOT build the C module.

Debian / Ubuntu

sudo apt install libpam-dev

RHEL / CentOS / Fedora

sudo yum install pam-devel
# or
sudo dnf install pam-devel

Arch Linux

sudo pacman -S linux-pam

Install

pip install .
# or
python setup.py install

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

cd pam_accessctl/
make
sudo make install

This compiles pam_accessctl.so and installs it to /lib/security/pam_accessctl.so.


Setup

1. Confirm server identity

cat /etc/accessctl/server-id

Edit 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.yaml

apply 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

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 accessctl line 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 sshd

CLI 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_ip

Apply a new policy (requires root)

sudo accessctl apply /path/to/new-policy.yaml
# policy applied: /etc/accessctl/policy.yaml
# compiled cache: /etc/accessctl/policy.json

Dry-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 allowlist

Exit 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    intern

Show current server identity

accessctl whoami
# prod-web-01

Policy 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):

  1. Is the server in the policy? → fail closed if not
  2. Is the user in the deny list? → deny
  3. Is the current time inside time_window? → deny if outside
  4. Is PAM_RHOST in source_ip? → deny if not
  5. 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-id is missing or unreadable
  • /etc/accessctl/policy.json is missing, unreadable, or contains invalid JSON
  • The server-id is not in the policy
  • PAM_USER is 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-check

The 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 -20

Architecture

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.yamlpolicy.json once,
so the login path never touches PyYAML.


Contributing

  1. Fork the repo and create a feature branch.
  2. Write tests in tests/ covering new behaviour.
  3. Run the test suite: pytest
  4. Keep check.py under 150 lines and stdlib-only.
  5. All writes to /etc/accessctl/ must be atomic (write .tmp, then os.rename).
  6. Open a pull request — CI runs pytest on Python 3.9, 3.10, 3.11, 3.12.
OdaiGH/Pam-accessctl | GitHunt