# Users and access

## Where users come from

| Source | Managed by | Durable |
| --- | --- | --- |
| `[[users]]` in the config file | You, in the file | Yes; read-only through the API |
| User store (`[server].user_store`) | Admin API and MCP | Yes; atomic writes, survives restart and crash |
| LDAP | Your directory | Session only; nothing is stored |

Lookup order at login: config file, then store, then LDAP.

## Permissions

| Bit | Allows |
| --- | --- |
| `r` | Download, SFTP read, WebDAV `GET` |
| `w` | Upload, SFTP write, `SETSTAT`, `MFMT`, symlinks |
| `d` | Delete files and directories |
| `n` | Rename (needed on both ends) |
| `m` | Create directories |
| `l` | List, stat, WebDAV `PROPFIND` |

A missing bit is `permission_denied` before any file is touched.

## Groups and directory rules

```toml
[[groups]]
name = "staff"
permissions = "rwdnml"

[[groups.directories]]
path = "/archive"
permissions = "rl"

[[users]]
name = "carol"
password_hash = "$argon2id$..."
groups = ["staff"]

[[users.directories]]
path = "/incoming"
permissions = "wl"
```

- Base bits: the user's `permissions`; if absent, the union of their
  groups' `permissions`; if no groups, `rl`.
- A rule covers its path and everything below it. The longest matching
  rule wins. At the same path a user rule beats group rules; group rules
  at the same path are combined.
- Rules replace the base for their subtree, so they can grant
  (`/incoming = "wl"` for a read-only user) or restrict
  (`/archive = "rl"`).
- Symlinks do not launder rights: an operation through a link is
  checked against both the link's path and the path it resolves to.

## SSH keys

```toml
ssh_public_keys = [
  'expiry-time="20271231" ssh-ed25519 AAAAC3... laptop',
  "no-pty ecdsa-sha2-nistp256 AAAAE2... ci",
]
```

- `expiry-time` (UTC, `YYYYMMDD[HHMM[SS]]`) stops the key at that time.
- `restrict`, `no-pty`, `no-port-forwarding`, `no-agent-forwarding`,
  `no-x11-forwarding`, `no-user-rc`, `no-touch-required` are accepted as
  no-ops (siftp never grants those). Any other option is a config error.
- Revoke keys server-wide in `[server].revoked_keys_file`: OpenSSH key
  lines or `SHA256:` fingerprints, `#` comments. It is re-read on reload.

## Passwords

- argon2id only: `siftpd hash-password`, or plaintext through the API
  (hashed server-side, never stored or echoed).
- Rotation:

```sh
curl -X POST -H "Authorization: Bearer $SIFTP_ADMIN_TOKEN" \
  http://127.0.0.1:8080/api/v1/users/bob/password
# {"name":"bob","generated":true,"password":"Xk4...","password_set_unix":1791849600}
```

  Without a body a 24-character password is generated and shown once.
  With `{"password":"..."}` the given one is set.
- Expiry: `[server].password_max_age_days` rejects passwords older than
  that, counted from `password_set_unix` (set by the API; add it by hand
  for config users). Clients see the normal `auth_failed`.

## Login protection

| Setting | Default | Effect |
| --- | --- | --- |
| `max_failed_logins` / `ban_window_secs` | 5 / 600 | Failures from one IP in the window ban it |
| `ban_duration_secs` | 900 | Ban length; covers every listener and the admin API |
| `max_connections` / `max_connections_per_ip` | 512 / 32 | Shared by FTP, FTPS, SFTP, WebDAV |
| `max_sessions` (per user) | unlimited | Concurrent logins per user |

Wrong admin or MCP tokens count as failed logins.

## LDAP

```toml
[ldap]
url = "ldaps://ldap.example.com"
bind_dn = "cn=siftp,ou=services,dc=example,dc=com"
bind_password = "${ENV:SIFTP_LDAP_PASSWORD}"
base_dn = "ou=people,dc=example,dc=com"
user_filter = "(uid={user})"
group_attribute = "memberOf"
require_group = true

[ldap.group_map]
"cn=ftp-staff,ou=groups,dc=example,dc=com" = "staff"
```

- Search-then-bind as above, or direct bind with
  `user_dn_template = "uid={user},ou=people,dc=example,dc=com"`.
- Use `ldaps://`, or `ldap://` with `starttls = true`; `ca_file` adds a
  private CA.
- Mapped groups give the user that group's permissions and directory
  rules; unmapped users get `default_permissions`, or are refused with
  `require_group = true`.
- Roots: `virtual_root_template` (`{user}` substituted), else
  `<server.root>/<user>`; same jail as everyone else.
- Empty passwords are refused before contacting the directory. If the
  directory is unreachable, logins fail as `auth_failed` and the audit
  log records `internal`.
