People sign in with their Active Directory account
auth.ldap checks a username and password against the domain controllers over LDAPS or StartTLS: found with a service account, checked by binding as the person. Groups, nested ones included, give staff roles and places in the tree. Accounts are tied to the objectGUID; the leader reads the directory every 15 minutes, suspends whom it disabled or removed and reinstates them after, and refuses a sync that would suspend most. Entra ID sends no groups for someone in more than 200, only where to fetch them; that was read as none and took an administrator's role away. It is now left as it was, and logged. An account made by SCIM or OIDC can be reinstated again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
fe310576e7
commit
f27d722111
31 files changed
+1815
-29
No files matched your search
+13
-8
@@ -26,14 +26,19 @@ What is left, most needed first. The detail is in the item each names.
|
||||
3. ~~**I3**~~ — done 2026-10-16: `ctl bench overhead`, figures in the
|
||||
README.
|
||||
4. ~~**I4**~~ — done 2026-10-17: several keys for one outside provider.
|
||||
5. **J2** — a judge the leader adds by itself is sized as init's is. (S)
|
||||
6. **J3** — HA setup puts the judges on nodes of their own. (S)
|
||||
7. **J8** — a judge budget that fits the machine, measured at install. (S)
|
||||
8. **F5** — large imports limited by the embedding engine's slots. (M)
|
||||
9. **I1** — answers to similar questions from the cache. (M)
|
||||
10. **J4** — the model mirror on more than one machine. (M)
|
||||
11. **I2** — the customer's MCP servers behind llmgw. (L)
|
||||
12. Smaller leftovers: audit before/after values, a tenant-admin role and
|
||||
5. ~~**Active Directory and Entra ID**~~ — done 2026-10-18: `auth.ldap`
|
||||
sign-in and sync, Entra's 200-group overflow kept from taking roles.
|
||||
Still to do: try it against a real domain controller (a Samba AD DC
|
||||
on Proxmox): LDAPS, nested groups, a disabled user suspended by the
|
||||
sync.
|
||||
6. **J2** — a judge the leader adds by itself is sized as init's is. (S)
|
||||
7. **J3** — HA setup puts the judges on nodes of their own. (S)
|
||||
8. **J8** — a judge budget that fits the machine, measured at install. (S)
|
||||
9. **F5** — large imports limited by the embedding engine's slots. (M)
|
||||
10. **I1** — answers to similar questions from the cache. (M)
|
||||
11. **J4** — the model mirror on more than one machine. (M)
|
||||
12. **I2** — the customer's MCP servers behind llmgw. (L)
|
||||
13. Smaller leftovers: audit before/after values, a tenant-admin role and
|
||||
forms hidden from viewers (C1); Parquet and a scheduled push for usage
|
||||
export (C4); English errors on Turkish pages (C24); H1 tried on the PoC;
|
||||
licences in video and audio (H5); nginx in front of several control
|
||||
|
||||
@@ -28,6 +28,13 @@ APIs change between minors without a deprecation period.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sign-in with Active Directory** (`auth.ldap`). People sign in on the gateway's form with their AD username or mail
|
||||
and password, checked against the domain controllers over LDAPS or StartTLS. Groups, nested ones included, give
|
||||
staff roles and places in the tree, as with OIDC. Accounts are tied to the `objectGUID` and made at first sign-in
|
||||
when `create_accounts` is on. Every 15 minutes the leader reads the directory. People disabled or deleted there are
|
||||
suspended and reinstated when they come back; roles and memberships follow their groups. A sync that would suspend
|
||||
more than half the accounts is refused. See docs/install.md, "Active Directory".
|
||||
|
||||
- **Several keys for one outside provider.** An external model takes `api_key_files`, each with an optional weight, in
|
||||
place of `api_key_file`. Each key is an endpoint of its own (`external:MODEL#1`, `#2`), so a key answering 429 is
|
||||
passed over while its breaker is open and the request goes to another. A key answering 401 is passed over too, and
|
||||
@@ -41,6 +48,11 @@ APIs change between minors without a deprecation period.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Entra ID's groups past 200 no longer take a role away.** For someone in more than 200 groups Entra sends none in
|
||||
the token, only a pointer to Graph. That was read as no groups, so an administrator lost their role at the next
|
||||
sign-in. Their role and memberships are now left as they were, and an error is logged saying what to change in the app
|
||||
registration. docs/install.md has an Entra ID section.
|
||||
- **An account made by SCIM or OIDC could not be reinstated.** Setting it active was refused as "never approved".
|
||||
- **An upgrade refits the judge an earlier `server init` installed.** Upgrading to v0.14.7 left such a judge as it was:
|
||||
no cores or one, 2 or 4 GB, the prompt cache on. On fewer than eight cores every request was refused until it was
|
||||
fixed by hand. Now the leader finds a judge whose entry is exactly what an earlier init wrote and gives it 2 GB, no
|
||||
|
||||
@@ -2541,6 +2541,82 @@ and point it at `https://<gateway>/scim/v2` with that token as the bearer.
|
||||
join and leave them. Membership in a group is removed when someone leaves
|
||||
it.
|
||||
|
||||
### Entra ID
|
||||
|
||||
Entra ID is an OpenID Connect provider like the others, with two things to
|
||||
set in the app registration:
|
||||
|
||||
1. **Redirect URI:** under Authentication, add a Web platform with
|
||||
`https://<gateway>/login/oidc/callback`. The issuer is
|
||||
`https://login.microsoftonline.com/<tenant-id>/v2.0`.
|
||||
2. **Groups in the token:** under Token configuration, add a groups claim.
|
||||
Choose **Groups assigned to the application**, and assign the groups you
|
||||
map in `roles` and `memberships` to the enterprise application. Entra
|
||||
sends groups by object id unless you choose to emit names
|
||||
(`sAMAccountName` for groups synced from AD), so write them the same way
|
||||
in the mapping.
|
||||
|
||||
Entra leaves the groups out of the token for someone in more than 200 of
|
||||
them and sends a pointer to Graph instead. llmgw then keeps that person's
|
||||
role and memberships as they were, rather than reading it as no groups, and
|
||||
logs an error saying so. "Groups assigned to the application" avoids this.
|
||||
So does SCIM below, which Entra also speaks: in the enterprise application
|
||||
under Provisioning, set the tenant URL to `https://<gateway>/scim/v2` and
|
||||
the secret token to the contents of `auth.scim.token_file`.
|
||||
|
||||
## Active Directory
|
||||
|
||||
With an on-premises Active Directory, people sign in on the gateway's own
|
||||
form with their AD username (`ada`, `CORP\ada`, `ada@corp.example`) and
|
||||
password. The gateway checks them with the domain controllers over LDAPS:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
enabled: true
|
||||
ldap:
|
||||
urls: [ldaps://dc1.corp.example, ldaps://dc2.corp.example] # tried in order
|
||||
ca_file: /etc/llmgw/corp-ca.pem # the domain's CA, when not in the system's
|
||||
bind_dn: CN=svc-llmgw,OU=Service,DC=corp,DC=example
|
||||
bind_password_file: /etc/llmgw/secrets/ldap-bind # root-only
|
||||
base_dn: OU=Staff,DC=corp,DC=example # where people are
|
||||
user_filter: (memberOf:1.2.840.113556.1.4.1941:=CN=llmgw-users,OU=Groups,DC=corp,DC=example)
|
||||
label: Acme # "Sign in with your Acme account"
|
||||
create_accounts: true
|
||||
roles: # by group CN or full DN; nested groups count
|
||||
admin: [llmgw-admins]
|
||||
operator: [llmgw-ops]
|
||||
memberships:
|
||||
ml-engineers: "team:Acme/ml"
|
||||
sync_interval: 15m # negative turns the sync off
|
||||
```
|
||||
|
||||
- **Service account:** an ordinary domain user is enough: it only reads.
|
||||
It finds the person and reads their groups. The person's password is
|
||||
checked by binding as them, and is never stored.
|
||||
- **Encryption:** `ldaps://`, or `ldap://` with `start_tls: true`.
|
||||
Plaintext LDAP is refused, since passwords would cross the network.
|
||||
- **Who may sign in:** enabled user objects under `base_dn` that match
|
||||
`user_filter`. The example admits members of `llmgw-users`, nested groups
|
||||
included.
|
||||
- **Accounts:** an account is tied to the person's `objectGUID`, so a changed
|
||||
mail or name is still the same account. An account that already exists
|
||||
with the person's mail is taken over. Without `create_accounts`, only
|
||||
people who already have an account can sign in. Local accounts, such as
|
||||
the administrator `server init` made, keep signing in with their own
|
||||
password.
|
||||
- **Sync:** every `sync_interval` the leader reads the directory.
|
||||
- Someone disabled or deleted in AD is suspended and their sessions
|
||||
end.
|
||||
- When they are enabled again they are reinstated. An account an
|
||||
administrator suspended by hand stays suspended.
|
||||
- Roles follow groups, and memberships the directory gave are taken away
|
||||
when the person leaves the group.
|
||||
- A sync that would suspend more than half of the directory's accounts is
|
||||
refused and logged, rather than locking everyone out over a wrong
|
||||
`base_dn` or filter.
|
||||
- **Passwords:** a person's password is changed in AD. The gateway sends no
|
||||
reset link for these accounts.
|
||||
|
||||
## Secrets in Vault
|
||||
|
||||
Every setting that names a file holding a secret — the store's `@address`,
|
||||
|
||||
@@ -37,6 +37,7 @@ rather than repeated.
|
||||
| `default_tpm` | `int` | [Auth](#auth) |
|
||||
| `signup_open` | `bool` | [Auth](#auth) |
|
||||
| `oidc` | `OIDC` | [Auth](#auth) |
|
||||
| `ldap` | `LDAP` | [Auth](#auth) |
|
||||
| `scim` | `SCIM` | [Auth](#auth) |
|
||||
| `max_key_ttl` | `time.Duration` | [Auth](#auth) |
|
||||
| `personal` | `Personal` | [Auth](#auth) |
|
||||
@@ -147,6 +148,20 @@ rather than repeated.
|
||||
| `tokenize_timeout` | `time.Duration` | [KVIndex](#kvindex) |
|
||||
| `min_blocks` | `int` | [KVIndex](#kvindex) |
|
||||
| `max_blocks` | `int` | [KVIndex](#kvindex) |
|
||||
| `urls` | `[]string` | [LDAP](#ldap) |
|
||||
| `start_tls` | `bool` | [LDAP](#ldap) |
|
||||
| `ca_file` | `string` | [LDAP](#ldap) |
|
||||
| `bind_dn` | `string` | [LDAP](#ldap) |
|
||||
| `bind_password_file` | `string` | [LDAP](#ldap) |
|
||||
| `base_dn` | `string` | [LDAP](#ldap) |
|
||||
| `group_base_dn` | `string` | [LDAP](#ldap) |
|
||||
| `user_filter` | `string` | [LDAP](#ldap) |
|
||||
| `label` | `string` | [LDAP](#ldap) |
|
||||
| `create_accounts` | `bool` | [LDAP](#ldap) |
|
||||
| `roles` | `map[string][]string` | [LDAP](#ldap) |
|
||||
| `memberships` | `map[string]string` | [LDAP](#ldap) |
|
||||
| `sync_interval` | `time.Duration` | [LDAP](#ldap) |
|
||||
| `timeout` | `time.Duration` | [LDAP](#ldap) |
|
||||
| `contact` | `string` | [Landing](#landing) |
|
||||
| `notice` | `string` | [Landing](#landing) |
|
||||
| `language` | `string` | [Landing](#landing) |
|
||||
@@ -619,6 +634,15 @@ OIDC is sign-in through an identity provider.
|
||||
|
||||
Takes a [OIDC](#oidc) block.
|
||||
|
||||
### `ldap`
|
||||
|
||||
`LDAP`
|
||||
|
||||
LDAP is sign-in against a directory, Active Directory above all, and
|
||||
the accounts kept in step with it.
|
||||
|
||||
Takes a [LDAP](#ldap) block.
|
||||
|
||||
### `scim`
|
||||
|
||||
`SCIM`
|
||||
@@ -1427,6 +1451,106 @@ MaxBlocks bounds how many of a prompt's blocks are hashed and looked
|
||||
up. Beyond it a longer match still helps, but the difference no longer
|
||||
decides anything. Zero takes 4096.
|
||||
|
||||
## LDAP
|
||||
|
||||
### `urls`
|
||||
|
||||
`[]string`
|
||||
|
||||
URLs are the directory's servers, tried in turn: ldaps:// (636), or
|
||||
ldap:// (389) with StartTLS.
|
||||
|
||||
### `start_tls`
|
||||
|
||||
`bool`
|
||||
|
||||
StartTLS upgrades an ldap:// connection before anything is sent. A
|
||||
password never crosses a connection without TLS: ldap:// without it
|
||||
is refused.
|
||||
|
||||
### `ca_file`
|
||||
|
||||
`string`
|
||||
|
||||
CAFile is the authority the servers' certificates are checked against,
|
||||
when it is not the system's: an AD Certificate Services root.
|
||||
|
||||
### `bind_dn`
|
||||
|
||||
`string`
|
||||
|
||||
BindDN and the file holding its password: a service account that may
|
||||
read users and groups. Nothing is written to the directory.
|
||||
|
||||
### `bind_password_file`
|
||||
|
||||
`string`
|
||||
|
||||
### `base_dn`
|
||||
|
||||
`string`
|
||||
|
||||
BaseDN is where people are searched for, subtree included.
|
||||
|
||||
### `group_base_dn`
|
||||
|
||||
`string`
|
||||
|
||||
GroupBaseDN is where their groups are; empty is BaseDN's domain root.
|
||||
|
||||
### `user_filter`
|
||||
|
||||
`string`
|
||||
|
||||
UserFilter narrows who may sign in, ANDed with the login match: a
|
||||
group (memberOf=...) or an OU's people only. Empty is every enabled
|
||||
user object.
|
||||
|
||||
### `label`
|
||||
|
||||
`string`
|
||||
|
||||
Label is what the form says to sign in with: "Sign in with your
|
||||
<label> account". Empty is "Active Directory".
|
||||
|
||||
### `create_accounts`
|
||||
|
||||
`bool`
|
||||
|
||||
CreateAccounts makes an account, active, for a person the directory
|
||||
vouches for and the gateway does not know. Off, only existing accounts
|
||||
sign in this way.
|
||||
|
||||
### `roles`
|
||||
|
||||
`map[string][]string`
|
||||
|
||||
Roles maps a staff role (viewer, operator, billing, admin) to the
|
||||
groups that give it, by name (CN) or whole DN. Set at every sign-in
|
||||
and sync: someone taken out of the group loses the role.
|
||||
|
||||
### `memberships`
|
||||
|
||||
`map[string]string`
|
||||
|
||||
Memberships maps a group, by CN or DN, to a place in the tree —
|
||||
org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT — joined at sign-in
|
||||
and kept by the sync, which also takes away a place whose group the
|
||||
person has left.
|
||||
|
||||
### `sync_interval`
|
||||
|
||||
`time.Duration`
|
||||
|
||||
SyncInterval is how often the leader reads the directory. Zero is
|
||||
15m; negative turns the sync off, leaving only sign-in.
|
||||
|
||||
### `timeout`
|
||||
|
||||
`time.Duration`
|
||||
|
||||
Timeout bounds each round trip to a server. Zero is 10s.
|
||||
|
||||
## Landing
|
||||
|
||||
### `contact`
|
||||
|
||||
@@ -12,6 +12,7 @@ require (
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/go-ldap/ldap/v3 v3.4.15
|
||||
github.com/go-zeromq/zmq4 v0.17.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1
|
||||
@@ -45,6 +46,7 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
@@ -57,6 +59,7 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
|
||||
@@ -8,8 +8,12 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/NVIDIA/go-nvml v0.13.4-0 h1:o3jp9u2x1R9ShFE3v+Aesp55XOSIQFMJz/VGNUcJaNE=
|
||||
github.com/NVIDIA/go-nvml v0.13.4-0/go.mod h1:id63qwpoDWpFXwnwM6psDCSqW4BmNu6mWpr4YeQtPGo=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -46,8 +50,12 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -69,6 +77,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -81,6 +91,18 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
type fakeDirectory struct {
|
||||
asked []string
|
||||
u store.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDirectory) SignIn(_ context.Context, login, _ string) (store.User, error) {
|
||||
f.asked = append(f.asked, login)
|
||||
return f.u, f.err
|
||||
}
|
||||
|
||||
// With a directory, a name the store has no password for is the
|
||||
// directory's to check; an account with a password here — the
|
||||
// administrator init made — is still checked here, so a directory that is
|
||||
// down locks nobody out.
|
||||
func TestPasswordsKeptElsewhereAreTheDirectorysToCheck(t *testing.T) {
|
||||
svc, st := newService(t)
|
||||
ctx := t.Context()
|
||||
hash, _ := HashPassword("local-secret")
|
||||
admin := store.User{ID: "usr-admin", Email: "admin@example.com", Name: "a", PasswordHash: hash,
|
||||
State: store.UserActive, CreatedAt: time.Now()}
|
||||
ada := store.User{ID: "usr-ada", Email: "ada@corp.example", Name: "Ada", PasswordHash: "!ldap",
|
||||
State: store.UserActive, CreatedAt: time.Now()}
|
||||
for _, u := range []store.User{admin, ada} {
|
||||
if err := st.CreateUser(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
dir := &fakeDirectory{u: ada}
|
||||
svc.SetDirectory(dir)
|
||||
|
||||
if _, _, err := svc.SignIn(ctx, "admin@example.com", "local-secret"); err != nil || len(dir.asked) != 0 {
|
||||
t.Fatalf("the local administrator: %v, directory asked %v", err, dir.asked)
|
||||
}
|
||||
// A name, not an address, and an account kept by the directory.
|
||||
for _, login := range []string{`CORP\ada`, "ada@corp.example"} {
|
||||
if tok, _, err := svc.SignIn(ctx, login, "ad-secret"); err != nil || tok == "" {
|
||||
t.Errorf("%s: %v", login, err)
|
||||
}
|
||||
}
|
||||
if len(dir.asked) != 2 || dir.asked[0] != `CORP\ada` {
|
||||
t.Errorf("the directory was asked %v", dir.asked)
|
||||
}
|
||||
dir.err = ErrBadCredentials
|
||||
if _, _, err := svc.SignIn(ctx, "ada", "wrong"); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Errorf("a password the directory refused: %v", err)
|
||||
}
|
||||
// A reset link would give a directory's account a password here.
|
||||
if _, _, err := svc.RequestReset(ctx, st, "ada@corp.example"); !errors.Is(err, ErrNoReset) {
|
||||
t.Errorf("a directory's account was sent a reset link: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,26 @@ type Service struct {
|
||||
|
||||
mu sync.Mutex
|
||||
attempts map[string]*attemptWindow
|
||||
|
||||
// dir checks the passwords of accounts this store keeps none for.
|
||||
dir Directory
|
||||
}
|
||||
|
||||
// Directory checks a password against an outside directory — Active
|
||||
// Directory over LDAP — and answers the account to sign in: made or kept in
|
||||
// step with the directory. Its errors are this package's ErrBadCredentials
|
||||
// and ErrSuspended for the person's own, and anything else for the
|
||||
// directory's.
|
||||
type Directory interface {
|
||||
SignIn(ctx context.Context, login, password string) (store.User, error)
|
||||
}
|
||||
|
||||
// SetDirectory has passwords for accounts with none here, and names that are
|
||||
// not addresses, checked by the directory. An account with a password here
|
||||
// — the administrator init made — is still checked here, so a directory that
|
||||
// is down locks nobody out.
|
||||
func (s *Service) SetDirectory(d Directory) { s.dir = d }
|
||||
|
||||
type attemptWindow struct {
|
||||
count int
|
||||
since time.Time
|
||||
@@ -137,6 +155,7 @@ func (s *Service) SignUp(ctx context.Context, email, name, password string) erro
|
||||
// SignIn checks a password and opens a session, returning the session token and
|
||||
// the CSRF token that must accompany the forms in it.
|
||||
func (s *Service) SignIn(ctx context.Context, email, password string) (token, csrf string, err error) {
|
||||
login := strings.TrimSpace(email)
|
||||
email = store.NormalizeEmail(email)
|
||||
// Counted per address and the client trying it, so somebody guessing
|
||||
// from elsewhere cannot lock the owner out of their own account; and per
|
||||
@@ -151,6 +170,15 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
|
||||
}
|
||||
|
||||
u, err := s.store.UserByEmail(ctx, email)
|
||||
// No password kept here — the directory's, or nobody's — and a
|
||||
// directory to ask: it decides.
|
||||
if s.dir != nil && (errors.Is(err, store.ErrNotFound) || (err == nil && strings.HasPrefix(u.PasswordHash, "!"))) {
|
||||
if u, err = s.dir.SignIn(ctx, login, password); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
s.clearAttempts(byClient)
|
||||
return s.open(ctx, u)
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// Verify anyway, against a hash that cannot match. Returning early here
|
||||
// would let a stopwatch enumerate every account on the system.
|
||||
@@ -180,7 +208,11 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
|
||||
case store.UserSuspended:
|
||||
return "", "", ErrSuspended
|
||||
}
|
||||
return s.open(ctx, u)
|
||||
}
|
||||
|
||||
// open makes a session for a person whose password was right.
|
||||
func (s *Service) open(ctx context.Context, u store.User) (token, csrf string, err error) {
|
||||
if token, err = store.NewSessionToken(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -402,7 +434,10 @@ func (s *Service) RequestReset(ctx context.Context, rs ResetStore, email string)
|
||||
return "", store.User{}, ErrTooManyTries
|
||||
}
|
||||
u, err := rs.UserByEmail(ctx, email)
|
||||
if err != nil || u.PasswordHash == "" || u.State != store.UserActive {
|
||||
// An account whose password is kept elsewhere — "!oidc", "!scim",
|
||||
// "!ldap" — gets no link: setting one here would give it a second way
|
||||
// in that its directory knows nothing of, and outlives its leaving.
|
||||
if err != nil || u.PasswordHash == "" || strings.HasPrefix(u.PasswordHash, "!") || u.State != store.UserActive {
|
||||
return "", store.User{}, ErrNoReset
|
||||
}
|
||||
token, err := rs.CreatePasswordReset(ctx, u.ID, ResetLinkTTL)
|
||||
|
||||
@@ -442,6 +442,9 @@ type Auth struct {
|
||||
|
||||
// OIDC is sign-in through an identity provider.
|
||||
OIDC OIDC `koanf:"oidc"`
|
||||
// LDAP is sign-in against a directory, Active Directory above all, and
|
||||
// the accounts kept in step with it.
|
||||
LDAP LDAP `koanf:"ldap"`
|
||||
// SCIM is account provisioning by the identity provider.
|
||||
SCIM SCIM `koanf:"scim"`
|
||||
|
||||
@@ -968,6 +971,12 @@ func (s Server) Validate() error {
|
||||
if err := s.Auth.OIDC.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Auth.LDAP.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Auth.LDAP.Enabled() && !s.Auth.Enabled {
|
||||
return fmt.Errorf("auth.ldap needs auth.enabled: it signs people in to the accounts the store keeps")
|
||||
}
|
||||
if err := s.AgentTLS.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LDAP is sign-in against a directory — Active Directory above all — with
|
||||
// the directory's own passwords, and the accounts kept in step with it.
|
||||
//
|
||||
// auth:
|
||||
// ldap:
|
||||
// urls: [ldaps://dc1.corp.example.com, ldaps://dc2.corp.example.com]
|
||||
// bind_dn: CN=llmgw,OU=Service Accounts,DC=corp,DC=example,DC=com
|
||||
// bind_password_file: /etc/llmgw/secrets/ldap-bind
|
||||
// base_dn: OU=Staff,DC=corp,DC=example,DC=com
|
||||
// create_accounts: true
|
||||
// roles: {admin: [llmgw-admins]}
|
||||
// memberships: {ml-team: "team:Acme/ml"}
|
||||
//
|
||||
// A person signs in on the portal's form with their sAMAccountName, their
|
||||
// userPrincipalName or their mail, and their directory password. The
|
||||
// gateway finds them with the bind account, checks the password by binding
|
||||
// as them, and reads their groups, nested ones included. Every sync_interval
|
||||
// the leader reads the directory again: an account disabled or gone there
|
||||
// is suspended here, its sessions ended and its API keys refused.
|
||||
type LDAP struct {
|
||||
// URLs are the directory's servers, tried in turn: ldaps:// (636), or
|
||||
// ldap:// (389) with StartTLS.
|
||||
URLs []string `koanf:"urls"`
|
||||
// StartTLS upgrades an ldap:// connection before anything is sent. A
|
||||
// password never crosses a connection without TLS: ldap:// without it
|
||||
// is refused.
|
||||
StartTLS bool `koanf:"start_tls"`
|
||||
// CAFile is the authority the servers' certificates are checked against,
|
||||
// when it is not the system's: an AD Certificate Services root.
|
||||
CAFile string `koanf:"ca_file"`
|
||||
// BindDN and the file holding its password: a service account that may
|
||||
// read users and groups. Nothing is written to the directory.
|
||||
BindDN string `koanf:"bind_dn"`
|
||||
BindPasswordFile string `koanf:"bind_password_file"`
|
||||
// BaseDN is where people are searched for, subtree included.
|
||||
BaseDN string `koanf:"base_dn"`
|
||||
// GroupBaseDN is where their groups are; empty is BaseDN's domain root.
|
||||
GroupBaseDN string `koanf:"group_base_dn"`
|
||||
// UserFilter narrows who may sign in, ANDed with the login match: a
|
||||
// group (memberOf=...) or an OU's people only. Empty is every enabled
|
||||
// user object.
|
||||
UserFilter string `koanf:"user_filter"`
|
||||
// Label is what the form says to sign in with: "Sign in with your
|
||||
// <label> account". Empty is "Active Directory".
|
||||
Label string `koanf:"label"`
|
||||
// CreateAccounts makes an account, active, for a person the directory
|
||||
// vouches for and the gateway does not know. Off, only existing accounts
|
||||
// sign in this way.
|
||||
CreateAccounts bool `koanf:"create_accounts"`
|
||||
// Roles maps a staff role (viewer, operator, billing, admin) to the
|
||||
// groups that give it, by name (CN) or whole DN. Set at every sign-in
|
||||
// and sync: someone taken out of the group loses the role.
|
||||
Roles map[string][]string `koanf:"roles"`
|
||||
// Memberships maps a group, by CN or DN, to a place in the tree —
|
||||
// org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT — joined at sign-in
|
||||
// and kept by the sync, which also takes away a place whose group the
|
||||
// person has left.
|
||||
Memberships map[string]string `koanf:"memberships"`
|
||||
// SyncInterval is how often the leader reads the directory. Zero is
|
||||
// 15m; negative turns the sync off, leaving only sign-in.
|
||||
SyncInterval time.Duration `koanf:"sync_interval"`
|
||||
// Timeout bounds each round trip to a server. Zero is 10s.
|
||||
Timeout time.Duration `koanf:"timeout"`
|
||||
}
|
||||
|
||||
// Enabled reports whether directory sign-in is configured.
|
||||
func (l LDAP) Enabled() bool { return len(l.URLs) > 0 }
|
||||
|
||||
// Validate says what in the configuration cannot work.
|
||||
func (l LDAP) Validate() error {
|
||||
if !l.Enabled() {
|
||||
return nil
|
||||
}
|
||||
for _, raw := range l.URLs {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("auth.ldap.urls: %q is not an ldap:// or ldaps:// address", raw)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "ldaps":
|
||||
case "ldap":
|
||||
if !l.StartTLS {
|
||||
return fmt.Errorf("auth.ldap.urls: %q is plaintext: use ldaps://, or set start_tls; passwords would cross it", raw)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("auth.ldap.urls: %q is not an ldap:// or ldaps:// address", raw)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case strings.TrimSpace(l.BindDN) == "" || l.BindPasswordFile == "":
|
||||
return errors.New("auth.ldap: bind_dn and bind_password_file are needed, for the account that finds people")
|
||||
case strings.TrimSpace(l.BaseDN) == "":
|
||||
return errors.New("auth.ldap: base_dn is needed, where people are searched for")
|
||||
case l.UserFilter != "" && !(strings.HasPrefix(l.UserFilter, "(") && strings.HasSuffix(l.UserFilter, ")")):
|
||||
return fmt.Errorf("auth.ldap.user_filter: %q is not an LDAP filter in parentheses", l.UserFilter)
|
||||
}
|
||||
for role := range l.Roles {
|
||||
if !validStaffRole(role) {
|
||||
return fmt.Errorf("auth.ldap.roles: %q is not viewer, operator, billing or admin", role)
|
||||
}
|
||||
}
|
||||
for g, place := range l.Memberships {
|
||||
if !validPlace(place) {
|
||||
return fmt.Errorf("auth.ldap.memberships[%s]: %q is not org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT", g, place)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interval is SyncInterval with its default; zero means off.
|
||||
func (l LDAP) Interval() time.Duration {
|
||||
switch {
|
||||
case l.SyncInterval < 0:
|
||||
return 0
|
||||
case l.SyncInterval == 0:
|
||||
return 15 * time.Minute
|
||||
}
|
||||
return l.SyncInterval
|
||||
}
|
||||
|
||||
// RoundTrip is Timeout with its default.
|
||||
func (l LDAP) RoundTrip() time.Duration {
|
||||
if l.Timeout <= 0 {
|
||||
return 10 * time.Second
|
||||
}
|
||||
return l.Timeout
|
||||
}
|
||||
+18
-5
@@ -72,21 +72,34 @@ func (o OIDC) Validate() error {
|
||||
return errors.New("auth.oidc.client_id is required")
|
||||
}
|
||||
for role := range o.Roles {
|
||||
switch role {
|
||||
case "viewer", "operator", "billing", "admin":
|
||||
default:
|
||||
if !validStaffRole(role) {
|
||||
return fmt.Errorf("auth.oidc.roles: %q is not viewer, operator, billing or admin", role)
|
||||
}
|
||||
}
|
||||
for g, where := range o.Memberships {
|
||||
kind, path, ok := strings.Cut(where, ":")
|
||||
if !ok || path == "" || (kind != "org" && kind != "team" && kind != "project") {
|
||||
if !validPlace(where) {
|
||||
return fmt.Errorf("auth.oidc.memberships.%s is %q: org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT", g, where)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validStaffRole is a role groups may give: viewer, operator, billing, admin.
|
||||
func validStaffRole(role string) bool {
|
||||
switch role {
|
||||
case "viewer", "operator", "billing", "admin":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validPlace is a place in the tree a group may give: org:NAME,
|
||||
// team:ORG/TEAM or project:ORG/TEAM/PROJECT.
|
||||
func validPlace(where string) bool {
|
||||
kind, path, ok := strings.Cut(where, ":")
|
||||
return ok && path != "" && (kind == "org" || kind == "team" || kind == "project")
|
||||
}
|
||||
|
||||
// SCIM lets an identity provider provision accounts (/scim/v2): made,
|
||||
// suspended and reinstated as they are there, with groups mapped by
|
||||
// auth.oidc's roles and memberships.
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// Package directory keeps accounts in step with an outside directory —
|
||||
// Active Directory over LDAP — at sign-in and by a periodic sync: made for
|
||||
// people the directory vouches for, given the staff role and the places in
|
||||
// the tree their groups map to, and suspended when the directory disables or
|
||||
// loses them.
|
||||
package directory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// Identity is a person as the directory has them.
|
||||
type Identity struct {
|
||||
// ID never changes for the person: an AD objectGUID.
|
||||
ID string
|
||||
DN string
|
||||
Email string
|
||||
Name string
|
||||
// Groups are the person's groups, nested ones included, each by its CN
|
||||
// and by its DN, so a mapping may name either.
|
||||
Groups []string
|
||||
// Disabled is an account the directory has turned off.
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// Mapping is what a person's groups give them: a staff role and places in
|
||||
// the tree. The same shape as auth.oidc's.
|
||||
type Mapping struct {
|
||||
Roles map[string][]string
|
||||
Memberships map[string]string
|
||||
}
|
||||
|
||||
// Role is the highest staff role the groups give, "" when none.
|
||||
func (m Mapping) Role(groups []string) string {
|
||||
for _, r := range []string{store.RoleAdmin, store.RoleOperator, store.RoleBilling, store.RoleViewer} {
|
||||
if slices.ContainsFunc(m.Roles[r], func(g string) bool { return hasGroup(groups, g) }) {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Places are the places in the tree the groups give, each once.
|
||||
func (m Mapping) Places(groups []string) []string {
|
||||
var out []string
|
||||
for g, where := range m.Memberships {
|
||||
if hasGroup(groups, g) && !slices.Contains(out, where) {
|
||||
out = append(out, where)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Groups are every group the mapping names.
|
||||
func (m Mapping) Groups() []string {
|
||||
var out []string
|
||||
add := func(g string) {
|
||||
if !slices.ContainsFunc(out, func(o string) bool { return strings.EqualFold(o, g) }) {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
for _, gs := range m.Roles {
|
||||
for _, g := range gs {
|
||||
add(g)
|
||||
}
|
||||
}
|
||||
for g := range m.Memberships {
|
||||
add(g)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// hasGroup matches a group's CN or DN as AD does: case aside.
|
||||
func hasGroup(groups []string, g string) bool {
|
||||
return slices.ContainsFunc(groups, func(have string) bool { return strings.EqualFold(have, g) })
|
||||
}
|
||||
|
||||
// Store is what keeping accounts needs of the store.
|
||||
type Store interface {
|
||||
UserByEmail(ctx context.Context, email string) (store.User, error)
|
||||
UserByID(ctx context.Context, id string) (store.User, error)
|
||||
CreateUser(ctx context.Context, u store.User) error
|
||||
SetStaffRole(ctx context.Context, id, role string) error
|
||||
SetUserState(ctx context.Context, id string, state store.UserState) error
|
||||
DeleteUserSessions(ctx context.Context, userID string) error
|
||||
AddMembership(ctx context.Context, user string, scope store.Scope, id, role, by string, at time.Time) error
|
||||
RemoveMembership(ctx context.Context, userID string, scope store.Scope, scopeID string, at time.Time) (int, error)
|
||||
MembershipsOf(ctx context.Context, userID string) ([]store.Membership, error)
|
||||
AllOrgs(ctx context.Context) ([]store.Org, error)
|
||||
AllTeams(ctx context.Context) ([]store.TeamPath, error)
|
||||
AllProjects(ctx context.Context) ([]store.ProjectPath, error)
|
||||
DirectoryUser(ctx context.Context, directory, externalID string) (store.DirectoryLink, error)
|
||||
LinkDirectory(ctx context.Context, userID, directory, externalID string, at time.Time) error
|
||||
DirectoryLinks(ctx context.Context, directory string) ([]store.DirectoryLink, error)
|
||||
SetSyncSuspended(ctx context.Context, userID string, suspended bool, at time.Time) error
|
||||
}
|
||||
|
||||
// Keeper keeps one directory's accounts.
|
||||
type Keeper struct {
|
||||
St Store
|
||||
// Name is the directory's, as links and memberships record it: "ldap".
|
||||
Name string
|
||||
Mapping Mapping
|
||||
CreateAccounts bool
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// The ways a person the directory vouches for is still refused.
|
||||
var (
|
||||
ErrNoAccount = errors.New("no account here, and accounts are not made for the directory's people")
|
||||
ErrSuspended = errors.New("the account is suspended here")
|
||||
ErrNotActive = errors.New("the account is not active here")
|
||||
ErrNoEmail = errors.New("the directory has no mail for this person")
|
||||
// ErrImplausible is a sync that would suspend too many at once: done
|
||||
// nothing, and said.
|
||||
ErrImplausible = errors.New("the directory's answer is implausible; nobody was suspended")
|
||||
)
|
||||
|
||||
func (k *Keeper) now() time.Time {
|
||||
if k.Now != nil {
|
||||
return k.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// SignIn is the account for a person the directory has just vouched for:
|
||||
// found by their directory id, else their mail, else made; given what their
|
||||
// groups map to; refused when it may not sign in.
|
||||
func (k *Keeper) SignIn(ctx context.Context, id Identity) (store.User, error) {
|
||||
u, link, err := k.account(ctx, id)
|
||||
if err != nil {
|
||||
return store.User{}, err
|
||||
}
|
||||
// The directory has them enabled, or the bind would have failed: an
|
||||
// account the sync suspended for being disabled there is theirs again.
|
||||
if u.State == store.UserSuspended && link.SuspendedBySync {
|
||||
if u, err = k.reinstate(ctx, u); err != nil {
|
||||
return store.User{}, err
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case u.State == store.UserSuspended:
|
||||
return store.User{}, ErrSuspended
|
||||
case !u.CanSignIn():
|
||||
return store.User{}, fmt.Errorf("%w: %s", ErrNotActive, u.State)
|
||||
}
|
||||
if err := k.apply(ctx, &u, id, false); err != nil {
|
||||
return store.User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// account finds or makes the account and links it to the directory.
|
||||
func (k *Keeper) account(ctx context.Context, id Identity) (store.User, store.DirectoryLink, error) {
|
||||
link, err := k.St.DirectoryUser(ctx, k.Name, id.ID)
|
||||
switch {
|
||||
case err == nil:
|
||||
u, err := k.St.UserByID(ctx, link.UserID)
|
||||
return u, link, err
|
||||
case !errors.Is(err, store.ErrNotFound):
|
||||
return store.User{}, link, err
|
||||
}
|
||||
email := store.NormalizeEmail(id.Email)
|
||||
if email == "" {
|
||||
return store.User{}, link, ErrNoEmail
|
||||
}
|
||||
u, err := k.St.UserByEmail(ctx, email)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound) && k.CreateAccounts:
|
||||
name := id.Name
|
||||
if name == "" {
|
||||
name = email
|
||||
}
|
||||
u = store.User{ID: "usr-" + randomID(), Email: email, Name: name,
|
||||
// No password works here: the directory's is checked.
|
||||
PasswordHash: "!" + k.Name, State: store.UserActive, CreatedAt: k.now()}
|
||||
if err := k.St.CreateUser(ctx, u); err != nil {
|
||||
return store.User{}, link, err
|
||||
}
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
return store.User{}, link, fmt.Errorf("%w: %s", ErrNoAccount, email)
|
||||
case err != nil:
|
||||
return store.User{}, link, err
|
||||
}
|
||||
if err := k.St.LinkDirectory(ctx, u.ID, k.Name, id.ID, k.now()); err != nil {
|
||||
return store.User{}, link, err
|
||||
}
|
||||
return u, store.DirectoryLink{UserID: u.ID, Directory: k.Name, ExternalID: id.ID}, nil
|
||||
}
|
||||
|
||||
// apply gives the account the role and places its groups map to. With
|
||||
// remove, a place this directory gave that the groups no longer do is taken
|
||||
// away; one given otherwise is never touched.
|
||||
func (k *Keeper) apply(ctx context.Context, u *store.User, id Identity, remove bool) error {
|
||||
if len(k.Mapping.Roles) > 0 {
|
||||
if role := k.Mapping.Role(id.Groups); role != u.StaffRole() {
|
||||
if err := k.St.SetStaffRole(ctx, u.ID, role); err != nil {
|
||||
return err
|
||||
}
|
||||
u.IsAdmin, u.Role = role == store.RoleAdmin, role
|
||||
if role == store.RoleAdmin {
|
||||
u.Role = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
want := map[string]store.Scope{}
|
||||
for _, where := range k.Mapping.Places(id.Groups) {
|
||||
scope, sid, err := k.place(ctx, where)
|
||||
if err != nil {
|
||||
continue // a mapping to a place the tree lacks: said by Sync
|
||||
}
|
||||
want[string(scope)+"/"+sid] = scope
|
||||
if err := k.St.AddMembership(ctx, u.ID, scope, sid, store.RoleMember, k.Name, k.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !remove {
|
||||
return nil
|
||||
}
|
||||
have, err := k.St.MembershipsOf(ctx, u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range have {
|
||||
if m.AddedBy != k.Name {
|
||||
continue
|
||||
}
|
||||
if _, ok := want[string(m.Scope)+"/"+m.ScopeID]; ok {
|
||||
continue
|
||||
}
|
||||
if _, err := k.St.RemoveMembership(ctx, u.ID, m.Scope, m.ScopeID, k.now()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *Keeper) reinstate(ctx context.Context, u store.User) (store.User, error) {
|
||||
if err := k.St.SetUserState(ctx, u.ID, store.UserActive); err != nil {
|
||||
return u, err
|
||||
}
|
||||
if err := k.St.SetSyncSuspended(ctx, u.ID, false, k.now()); err != nil {
|
||||
return u, err
|
||||
}
|
||||
u.State = store.UserActive
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// SyncReport is what a sync did.
|
||||
type SyncReport struct {
|
||||
Seen, Updated, Suspended, Reinstated int
|
||||
// Unmapped are mappings to places the tree does not have.
|
||||
Unmapped []string
|
||||
}
|
||||
|
||||
// Sync brings every account the directory keeps in step with what it says
|
||||
// now: people disabled or gone there are suspended here (their sessions
|
||||
// ended; their keys refuse, as a suspended owner's do), those enabled again
|
||||
// that the sync had suspended are reinstated, and everyone's role and places
|
||||
// follow their groups. People the directory has that never signed in here
|
||||
// are left until they do.
|
||||
func (k *Keeper) Sync(ctx context.Context, people []Identity) (SyncReport, error) {
|
||||
var rep SyncReport
|
||||
rep.Unmapped = k.unmapped(ctx)
|
||||
byID := make(map[string]Identity, len(people))
|
||||
for _, p := range people {
|
||||
byID[p.ID] = p
|
||||
}
|
||||
links, err := k.St.DirectoryLinks(ctx, k.Name)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
// A directory that answers with nobody, or with half the people missing,
|
||||
// is far likelier a wrong base, a filter or a bind account's lost rights
|
||||
// than half the company leaving at once. Nothing is suspended for it.
|
||||
gone := 0
|
||||
for _, l := range links {
|
||||
if p, there := byID[l.ExternalID]; !there || p.Disabled {
|
||||
gone++
|
||||
}
|
||||
}
|
||||
if len(links) > 0 && (len(people) == 0 || (gone >= 3 && gone*2 > len(links))) {
|
||||
return rep, fmt.Errorf("%w: %d of the %d accounts it keeps would be suspended", ErrImplausible, gone, len(links))
|
||||
}
|
||||
for _, l := range links {
|
||||
rep.Seen++
|
||||
u, err := k.St.UserByID(ctx, l.UserID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p, there := byID[l.ExternalID]
|
||||
switch {
|
||||
case !there || p.Disabled:
|
||||
if u.State == store.UserActive {
|
||||
if err := k.St.SetUserState(ctx, u.ID, store.UserSuspended); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
if err := k.St.DeleteUserSessions(ctx, u.ID); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
if err := k.St.SetSyncSuspended(ctx, u.ID, true, k.now()); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
rep.Suspended++
|
||||
}
|
||||
default:
|
||||
if u.State == store.UserSuspended && l.SuspendedBySync {
|
||||
if u, err = k.reinstate(ctx, u); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
rep.Reinstated++
|
||||
}
|
||||
if u.State != store.UserActive {
|
||||
continue
|
||||
}
|
||||
before := u.StaffRole()
|
||||
if err := k.apply(ctx, &u, p, true); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
if u.StaffRole() != before {
|
||||
rep.Updated++
|
||||
}
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// unmapped are the mapping's places the tree does not have.
|
||||
func (k *Keeper) unmapped(ctx context.Context) []string {
|
||||
var out []string
|
||||
for _, where := range k.Mapping.Memberships {
|
||||
if _, _, err := k.place(ctx, where); err != nil && !slices.Contains(out, where) {
|
||||
out = append(out, where)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// place finds org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT, as
|
||||
// auth.oidc's mapping does.
|
||||
func (k *Keeper) place(ctx context.Context, where string) (store.Scope, string, error) {
|
||||
kind, path, _ := strings.Cut(where, ":")
|
||||
norm := func(s string) string {
|
||||
parts := strings.Split(s, "/")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
same := func(a string) bool { return strings.EqualFold(norm(a), norm(path)) }
|
||||
switch kind {
|
||||
case "org":
|
||||
orgs, err := k.St.AllOrgs(ctx)
|
||||
for _, o := range orgs {
|
||||
if same(o.Name) {
|
||||
return store.ScopeOrg, o.ID, nil
|
||||
}
|
||||
}
|
||||
return "", "", errors.Join(err, errors.New("no such organization"))
|
||||
case "team":
|
||||
teams, err := k.St.AllTeams(ctx)
|
||||
for _, t := range teams {
|
||||
if same(t.OrgName + "/" + t.Name) {
|
||||
return store.ScopeTeam, t.ID, nil
|
||||
}
|
||||
}
|
||||
return "", "", errors.Join(err, errors.New("no such team"))
|
||||
default:
|
||||
projects, err := k.St.AllProjects(ctx)
|
||||
for _, p := range projects {
|
||||
if same(p.Path) {
|
||||
return store.ScopeProject, p.ID, nil
|
||||
}
|
||||
}
|
||||
return "", "", errors.Join(err, errors.New("no such project"))
|
||||
}
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package directory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func newKeeper(t *testing.T) (*Keeper, *store.DB, string) {
|
||||
t.Helper()
|
||||
st, err := store.Open(t.Context(), filepath.Join(t.TempDir(), "s.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
org, _ := st.CreateOrg(t.Context(), "Acme")
|
||||
team, _ := st.CreateTeam(t.Context(), org, "ml")
|
||||
k := &Keeper{St: st, Name: "ldap", CreateAccounts: true, Mapping: Mapping{
|
||||
Roles: map[string][]string{"admin": {"llmgw-admins"}, "viewer": {"CN=Staff,OU=Groups,DC=corp,DC=example"}},
|
||||
Memberships: map[string]string{"ml-team": "team:Acme/ml", "ghosts": "team:Acme/nowhere"},
|
||||
}}
|
||||
return k, st, team
|
||||
}
|
||||
|
||||
var ada = Identity{ID: "guid-ada", DN: "CN=Ada,OU=Staff,DC=corp,DC=example", Email: "Ada@Corp.example", Name: "Ada L",
|
||||
Groups: []string{"ml-team", "CN=ml-team,OU=Groups,DC=corp,DC=example", "Staff", "CN=Staff,OU=Groups,DC=corp,DC=example"}}
|
||||
|
||||
func TestAPersonTheDirectoryVouchesForIsMadeAnAccountWithWhatTheirGroupsGive(t *testing.T) {
|
||||
k, st, team := newKeeper(t)
|
||||
u, err := k.SignIn(t.Context(), ada)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Email != "ada@corp.example" || u.Name != "Ada L" || u.PasswordHash != "!ldap" || u.StaffRole() != "viewer" {
|
||||
t.Errorf("account %+v role %q", u, u.StaffRole())
|
||||
}
|
||||
ms, _ := st.MembershipsOf(t.Context(), u.ID)
|
||||
if len(ms) != 1 || ms[0].ScopeID != team || ms[0].AddedBy != "ldap" {
|
||||
t.Errorf("memberships %+v", ms)
|
||||
}
|
||||
// Their mail changes there: still the same account, by its directory id.
|
||||
moved := ada
|
||||
moved.Email = "ada.lovelace@corp.example"
|
||||
again, err := k.SignIn(t.Context(), moved)
|
||||
if err != nil || again.ID != u.ID {
|
||||
t.Errorf("a new mail made a new account: %v %+v", err, again)
|
||||
}
|
||||
// The higher role of two wins.
|
||||
boss := ada
|
||||
boss.Groups = append(slices.Clone(ada.Groups), "llmgw-admins")
|
||||
if u, _ := k.SignIn(t.Context(), boss); u.StaffRole() != "admin" {
|
||||
t.Errorf("role %q", u.StaffRole())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutAccountsMadeOnlyThoseThatExistSignIn(t *testing.T) {
|
||||
k, _, _ := newKeeper(t)
|
||||
k.CreateAccounts = false
|
||||
if _, err := k.SignIn(t.Context(), ada); !errors.Is(err, ErrNoAccount) {
|
||||
t.Errorf("err %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSyncSuspendsWhomTheDirectoryDisabledAndReinstatesThemAfter(t *testing.T) {
|
||||
k, st, _ := newKeeper(t)
|
||||
ctx := t.Context()
|
||||
people := []Identity{ada}
|
||||
for _, n := range []string{"b", "c", "d"} {
|
||||
people = append(people, Identity{ID: "guid-" + n, Email: n + "@corp.example"})
|
||||
}
|
||||
ids := map[string]string{}
|
||||
for _, p := range people {
|
||||
u, err := k.SignIn(ctx, p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[p.ID] = u.ID
|
||||
}
|
||||
if err := st.CreateSession(ctx, ids["guid-ada"], "tok", "csrf", time.Hour); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An administrator suspends c here, by hand.
|
||||
_ = st.SetUserState(ctx, ids["guid-c"], store.UserSuspended)
|
||||
|
||||
// Ada is disabled there, and has left ml-team.
|
||||
off := ada
|
||||
off.Disabled = true
|
||||
rep, err := k.Sync(ctx, []Identity{off, people[1], people[2], people[3]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ := st.UserByID(ctx, ids["guid-ada"])
|
||||
if u.State != store.UserSuspended || rep.Suspended != 1 {
|
||||
t.Errorf("ada %s, report %+v", u.State, rep)
|
||||
}
|
||||
if _, _, err := st.SessionByToken(ctx, "tok"); err == nil {
|
||||
t.Error("a suspended account's session still works")
|
||||
}
|
||||
if !slices.Contains(rep.Unmapped, "team:Acme/nowhere") {
|
||||
t.Errorf("an unmapped place was not said: %+v", rep)
|
||||
}
|
||||
|
||||
// Enabled again, and out of ml-team: reinstated, and its place taken
|
||||
// away. c, suspended by hand, stays so.
|
||||
back := ada
|
||||
back.Groups = []string{"Staff", "CN=Staff,OU=Groups,DC=corp,DC=example"}
|
||||
rep, err = k.Sync(ctx, []Identity{back, people[1], people[2], people[3]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ = st.UserByID(ctx, ids["guid-ada"])
|
||||
ms, _ := st.MembershipsOf(ctx, u.ID)
|
||||
if u.State != store.UserActive || rep.Reinstated != 1 || len(ms) != 0 {
|
||||
t.Errorf("ada %s, memberships %+v, report %+v", u.State, ms, rep)
|
||||
}
|
||||
if c, _ := st.UserByID(ctx, ids["guid-c"]); c.State != store.UserSuspended {
|
||||
t.Errorf("an account suspended by hand was reinstated: %s", c.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASyncThatWouldSuspendMostIsRefused(t *testing.T) {
|
||||
k, st, _ := newKeeper(t)
|
||||
ctx := t.Context()
|
||||
for _, n := range []string{"a", "b", "c", "d"} {
|
||||
if _, err := k.SignIn(ctx, Identity{ID: "guid-" + n, Email: n + "@corp.example"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, people := range [][]Identity{nil, {{ID: "guid-a", Email: "a@corp.example"}}} {
|
||||
if _, err := k.Sync(ctx, people); !errors.Is(err, ErrImplausible) {
|
||||
t.Errorf("%d people: %v", len(people), err)
|
||||
}
|
||||
}
|
||||
users, _ := st.Users(ctx, store.UserSuspended)
|
||||
if len(users) != 0 {
|
||||
t.Errorf("suspended %d", len(users))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package directory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
|
||||
)
|
||||
|
||||
// LDAP is a directory reached over LDAP, written for Active Directory: its
|
||||
// attribute names, its disabled flag, and its matching rule for nested
|
||||
// groups (LDAP_MATCHING_RULE_IN_CHAIN).
|
||||
type LDAP struct {
|
||||
cfg config.LDAP
|
||||
tls *tls.Config
|
||||
dial func(url string) (ldapConn, error)
|
||||
}
|
||||
|
||||
// ldapConn is the part of a connection used here, so tests need no server.
|
||||
type ldapConn interface {
|
||||
Bind(user, password string) error
|
||||
Search(*ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||
SearchWithPaging(*ldap.SearchRequest, uint32) (*ldap.SearchResult, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// inChain is AD's matching rule that follows nested group membership.
|
||||
const inChain = "1.2.840.113556.1.4.1941"
|
||||
|
||||
// uacDisabled is userAccountControl's ACCOUNTDISABLE bit.
|
||||
const uacDisabled = 0x2
|
||||
|
||||
var personAttrs = []string{"objectGUID", "sAMAccountName", "userPrincipalName", "mail", "displayName", "userAccountControl"}
|
||||
|
||||
// ErrBadCredentials is a person the directory does not know, or a password
|
||||
// it does not take: said alike, so a form cannot tell which.
|
||||
var ErrBadCredentials = errors.New("the directory does not take that name and password")
|
||||
|
||||
// NewLDAP prepares a directory from its configuration.
|
||||
func NewLDAP(cfg config.LDAP) (*LDAP, error) {
|
||||
tc := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if cfg.CAFile != "" {
|
||||
pem, err := os.ReadFile(cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth.ldap.ca_file: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("auth.ldap.ca_file %s holds no certificate", cfg.CAFile)
|
||||
}
|
||||
tc.RootCAs = pool
|
||||
}
|
||||
d := &LDAP{cfg: cfg, tls: tc}
|
||||
d.dial = d.dialURL
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *LDAP) dialURL(u string) (ldapConn, error) {
|
||||
c, err := ldap.DialURL(u, ldap.DialWithTLSConfig(d.tls))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.SetTimeout(d.cfg.RoundTrip())
|
||||
if strings.HasPrefix(u, "ldap://") {
|
||||
// Validate refuses ldap:// without start_tls: nothing is sent first.
|
||||
if err := c.StartTLS(d.tls); err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("StartTLS: %w", err)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// service is a connection bound as the account that finds people, to the
|
||||
// first server that answers.
|
||||
func (d *LDAP) service(ctx context.Context) (ldapConn, error) {
|
||||
pw, err := secrets.Read(ctx, d.cfg.BindPasswordFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth.ldap.bind_password_file: %w", err)
|
||||
}
|
||||
var errs []error
|
||||
for _, u := range d.cfg.URLs {
|
||||
c, err := d.dial(u)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", u, err))
|
||||
continue
|
||||
}
|
||||
if err := c.Bind(d.cfg.BindDN, strings.TrimSpace(string(pw))); err != nil {
|
||||
c.Close()
|
||||
// A server that refuses the service account refuses it
|
||||
// everywhere: not tried on the next.
|
||||
return nil, fmt.Errorf("binding as %s on %s: %w", d.cfg.BindDN, u, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no directory server answered: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
// Authenticate checks a person's name and password against the directory
|
||||
// and says who they are there, groups and all.
|
||||
func (d *LDAP) Authenticate(ctx context.Context, login, password string) (Identity, error) {
|
||||
login = strings.TrimSpace(login)
|
||||
// An empty password is an unauthenticated bind, which succeeds: never
|
||||
// one for a person.
|
||||
if login == "" || password == "" {
|
||||
return Identity{}, ErrBadCredentials
|
||||
}
|
||||
// DOMAIN\user, as Windows writes it.
|
||||
if i := strings.LastIndexByte(login, '\\'); i >= 0 {
|
||||
login = login[i+1:]
|
||||
}
|
||||
c, err := d.service(ctx)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
defer c.Close()
|
||||
q := ldap.EscapeFilter(login)
|
||||
filter := "(&" + d.people(false) + "(|(sAMAccountName=" + q + ")(userPrincipalName=" + q + ")(mail=" + q + ")))"
|
||||
res, err := c.Search(ldap.NewSearchRequest(d.cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
2, 0, false, filter, personAttrs, nil))
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("finding %s: %w", login, err)
|
||||
}
|
||||
if len(res.Entries) != 1 {
|
||||
// None, or two people one name matches: neither is let in.
|
||||
return Identity{}, ErrBadCredentials
|
||||
}
|
||||
id := identity(res.Entries[0])
|
||||
if err := c.Bind(id.DN, password); err != nil {
|
||||
var le *ldap.Error
|
||||
if errors.As(err, &le) && le.ResultCode == ldap.LDAPResultInvalidCredentials {
|
||||
return Identity{}, ErrBadCredentials
|
||||
}
|
||||
return Identity{}, fmt.Errorf("checking %s's password: %w", login, err)
|
||||
}
|
||||
// Back to the service account to read groups, which a person may not.
|
||||
pw, err := secrets.Read(ctx, d.cfg.BindPasswordFile)
|
||||
if err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
if err := c.Bind(d.cfg.BindDN, strings.TrimSpace(string(pw))); err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
if id.Groups, err = d.groupsOf(c, id.DN); err != nil {
|
||||
return Identity{}, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// groupsOf are a person's groups, nested ones included, by CN and DN.
|
||||
func (d *LDAP) groupsOf(c ldapConn, dn string) ([]string, error) {
|
||||
filter := "(&(objectClass=group)(member:" + inChain + ":=" + ldap.EscapeFilter(dn) + "))"
|
||||
res, err := c.SearchWithPaging(ldap.NewSearchRequest(d.groupBase(), ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
0, 0, false, filter, []string{"cn"}, nil), 500)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the groups of %s: %w", dn, err)
|
||||
}
|
||||
var out []string
|
||||
for _, e := range res.Entries {
|
||||
out = append(out, e.GetAttributeValue("cn"), e.DN)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// People are everyone the directory has under the base, enabled or not,
|
||||
// each with the mapped groups they are in: what a sync compares against.
|
||||
func (d *LDAP) People(ctx context.Context, m Mapping) ([]Identity, error) {
|
||||
c, err := d.service(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Close()
|
||||
res, err := c.SearchWithPaging(ldap.NewSearchRequest(d.cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
0, 0, false, d.people(true), personAttrs, nil), 500)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing people: %w", err)
|
||||
}
|
||||
people := make([]Identity, 0, len(res.Entries))
|
||||
at := map[string]int{}
|
||||
for _, e := range res.Entries {
|
||||
id := identity(e)
|
||||
at[id.ID] = len(people)
|
||||
people = append(people, id)
|
||||
}
|
||||
// Only the groups the mapping names matter: a query each, for its
|
||||
// members nested ones included, rather than one per person.
|
||||
for _, g := range m.Groups() {
|
||||
dns, err := d.groupDNs(c, g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, gdn := range dns {
|
||||
filter := "(&" + d.people(true) + "(memberOf:" + inChain + ":=" + ldap.EscapeFilter(gdn) + "))"
|
||||
r, err := c.SearchWithPaging(ldap.NewSearchRequest(d.cfg.BaseDN, ldap.ScopeWholeSubtree,
|
||||
ldap.NeverDerefAliases, 0, 0, false, filter, []string{"objectGUID"}, nil), 500)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the members of %s: %w", gdn, err)
|
||||
}
|
||||
for _, e := range r.Entries {
|
||||
if i, ok := at[guid(e.GetRawAttributeValue("objectGUID"))]; ok {
|
||||
people[i].Groups = append(people[i].Groups, g, gdn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return people, nil
|
||||
}
|
||||
|
||||
// groupDNs are the groups a mapping's name means: a DN as it is, a CN
|
||||
// looked up.
|
||||
func (d *LDAP) groupDNs(c ldapConn, g string) ([]string, error) {
|
||||
if strings.Contains(g, "=") {
|
||||
return []string{g}, nil
|
||||
}
|
||||
res, err := c.Search(ldap.NewSearchRequest(d.groupBase(), ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
0, 0, false, "(&(objectClass=group)(cn="+ldap.EscapeFilter(g)+"))", []string{"cn"}, nil))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("finding the group %s: %w", g, err)
|
||||
}
|
||||
var out []string
|
||||
for _, e := range res.Entries {
|
||||
out = append(out, e.DN)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// people is the filter for person accounts under the user filter; enabled
|
||||
// ones only, unless all.
|
||||
func (d *LDAP) people(all bool) string {
|
||||
f := "(objectCategory=person)(objectClass=user)"
|
||||
if !all {
|
||||
f += "(!(userAccountControl:1.2.840.113556.1.4.803:=" + strconv.Itoa(uacDisabled) + "))"
|
||||
}
|
||||
return "(&" + f + d.cfg.UserFilter + ")"
|
||||
}
|
||||
|
||||
// groupBase is where groups are searched: GroupBaseDN, else the domain root
|
||||
// of BaseDN (its DC= parts).
|
||||
func (d *LDAP) groupBase() string {
|
||||
if d.cfg.GroupBaseDN != "" {
|
||||
return d.cfg.GroupBaseDN
|
||||
}
|
||||
var dc []string
|
||||
for _, rdn := range strings.Split(d.cfg.BaseDN, ",") {
|
||||
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rdn)), "DC=") {
|
||||
dc = append(dc, strings.TrimSpace(rdn))
|
||||
}
|
||||
}
|
||||
if len(dc) == 0 {
|
||||
return d.cfg.BaseDN
|
||||
}
|
||||
return strings.Join(dc, ",")
|
||||
}
|
||||
|
||||
func identity(e *ldap.Entry) Identity {
|
||||
email := e.GetAttributeValue("mail")
|
||||
if email == "" {
|
||||
email = e.GetAttributeValue("userPrincipalName")
|
||||
}
|
||||
uac, _ := strconv.Atoi(e.GetAttributeValue("userAccountControl"))
|
||||
return Identity{
|
||||
ID: guid(e.GetRawAttributeValue("objectGUID")), DN: e.DN,
|
||||
Email: email, Name: e.GetAttributeValue("displayName"),
|
||||
Disabled: uac&uacDisabled != 0,
|
||||
}
|
||||
}
|
||||
|
||||
// guid is an objectGUID as Windows writes it: its first three parts
|
||||
// little-endian. Another directory's id is kept as hex.
|
||||
func guid(b []byte) string {
|
||||
if len(b) != 16 {
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
return fmt.Sprintf("%08x-%04x-%04x-%x-%x", binary.LittleEndian.Uint32(b[0:4]),
|
||||
binary.LittleEndian.Uint16(b[4:6]), binary.LittleEndian.Uint16(b[6:8]), b[8:10], b[10:16])
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package directory
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
)
|
||||
|
||||
// fakeConn is a directory with one person in it.
|
||||
type fakeConn struct {
|
||||
binds []string
|
||||
filters []string
|
||||
password string // the person's
|
||||
people int // how many the person search finds
|
||||
}
|
||||
|
||||
func (f *fakeConn) Bind(user, password string) error {
|
||||
f.binds = append(f.binds, user)
|
||||
if strings.HasPrefix(user, "CN=Ada") && password != f.password {
|
||||
return &ldap.Error{ResultCode: ldap.LDAPResultInvalidCredentials}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeConn) Search(r *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
f.filters = append(f.filters, r.Filter)
|
||||
var out []*ldap.Entry
|
||||
for i := range f.people {
|
||||
e := ldap.NewEntry("CN=Ada,OU=Staff,DC=corp,DC=example", map[string][]string{
|
||||
"mail": {"ada@corp.example"}, "displayName": {"Ada"}, "userAccountControl": {"512"},
|
||||
})
|
||||
e.Attributes = append(e.Attributes, &ldap.EntryAttribute{Name: "objectGUID", ByteValues: [][]byte{guidBytes(byte(i))}})
|
||||
out = append(out, e)
|
||||
}
|
||||
return &ldap.SearchResult{Entries: out}, nil
|
||||
}
|
||||
|
||||
func (f *fakeConn) SearchWithPaging(r *ldap.SearchRequest, _ uint32) (*ldap.SearchResult, error) {
|
||||
f.filters = append(f.filters, r.Filter)
|
||||
return &ldap.SearchResult{Entries: []*ldap.Entry{
|
||||
ldap.NewEntry("CN=ml-team,OU=Groups,DC=corp,DC=example", map[string][]string{"cn": {"ml-team"}}),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeConn) Close() error { return nil }
|
||||
|
||||
func guidBytes(last byte) []byte {
|
||||
return []byte{0x78, 0x56, 0x34, 0x12, 0x34, 0x12, 0x78, 0x56, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, last}
|
||||
}
|
||||
|
||||
func fakeLDAP(t *testing.T, c *fakeConn) *LDAP {
|
||||
t.Helper()
|
||||
pw := filepath.Join(t.TempDir(), "bind")
|
||||
_ = os.WriteFile(pw, []byte("svc-secret\n"), 0o600)
|
||||
d, err := NewLDAP(config.LDAP{URLs: []string{"ldaps://dc1"}, BindDN: "CN=llmgw,OU=Svc,DC=corp,DC=example",
|
||||
BindPasswordFile: pw, BaseDN: "OU=Staff,DC=corp,DC=example", UserFilter: "(memberOf=CN=llm,DC=corp,DC=example)"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.dial = func(string) (ldapConn, error) { return c, nil }
|
||||
return d
|
||||
}
|
||||
|
||||
func TestAPersonIsCheckedByBindingAsThemAndTheirGroupsReadNested(t *testing.T) {
|
||||
c := &fakeConn{password: "ad-secret", people: 1}
|
||||
d := fakeLDAP(t, c)
|
||||
id, err := d.Authenticate(t.Context(), `CORP\ada*)(x=`, "ad-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id.Email != "ada@corp.example" || id.ID != "12345678-1234-5678-9abc-def012345600" ||
|
||||
!slicesHas(id.Groups, "ml-team") || !slicesHas(id.Groups, "CN=ml-team,OU=Groups,DC=corp,DC=example") {
|
||||
t.Errorf("identity %+v", id)
|
||||
}
|
||||
// The name is escaped into the filter, the domain part dropped; the
|
||||
// user filter is ANDed in; disabled accounts are not found; groups are
|
||||
// read with the in-chain rule.
|
||||
find, groups := c.filters[0], c.filters[1]
|
||||
if !strings.Contains(find, `(sAMAccountName=ada\2a\29\28x=)`) || !strings.Contains(find, "(memberOf=CN=llm,DC=corp,DC=example)") ||
|
||||
!strings.Contains(find, "1.2.840.113556.1.4.803:=2") || !strings.Contains(groups, inChain) {
|
||||
t.Errorf("filters %q", c.filters)
|
||||
}
|
||||
// Service account, then the person, then the service account again.
|
||||
if len(c.binds) != 3 || c.binds[1] != "CN=Ada,OU=Staff,DC=corp,DC=example" {
|
||||
t.Errorf("binds %v", c.binds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongPasswordsEmptyOnesAndAmbiguousNamesAreRefusedAlike(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
people int
|
||||
password string
|
||||
}{{1, "wrong"}, {1, ""}, {0, "ad-secret"}, {2, "ad-secret"}} {
|
||||
d := fakeLDAP(t, &fakeConn{password: "ad-secret", people: tc.people})
|
||||
if _, err := d.Authenticate(t.Context(), "ada", tc.password); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Errorf("%d found, password %q: %v", tc.people, tc.password, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextLDAPIsRefusedWithoutStartTLS(t *testing.T) {
|
||||
base := config.LDAP{BindDN: "CN=x", BindPasswordFile: "/f", BaseDN: "DC=corp"}
|
||||
for _, c := range []struct {
|
||||
urls []string
|
||||
start bool
|
||||
ok bool
|
||||
}{{[]string{"ldaps://dc1"}, false, true}, {[]string{"ldap://dc1"}, false, false},
|
||||
{[]string{"ldap://dc1"}, true, true}, {[]string{"http://dc1"}, true, false}} {
|
||||
l := base
|
||||
l.URLs, l.StartTLS = c.urls, c.start
|
||||
if err := l.Validate(); (err == nil) != c.ok {
|
||||
t.Errorf("%v start_tls=%v: %v", c.urls, c.start, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func slicesHas(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -33,6 +33,7 @@ var Reference = []Block{
|
||||
{Key: "default_tpm", Type: "int", Doc: "", Nested: ""},
|
||||
{Key: "signup_open", Type: "bool", Doc: "SignupOpen serves the signup form. Accounts it creates are pending until\nan operator approves them, so this is not a way in — but turning it off\nmeans accounts are made with `llmgw users create` instead.", Nested: ""},
|
||||
{Key: "oidc", Type: "OIDC", Doc: "OIDC is sign-in through an identity provider.", Nested: "config.OIDC"},
|
||||
{Key: "ldap", Type: "LDAP", Doc: "LDAP is sign-in against a directory, Active Directory above all, and\nthe accounts kept in step with it.", Nested: "config.LDAP"},
|
||||
{Key: "scim", Type: "SCIM", Doc: "SCIM is account provisioning by the identity provider.", Nested: "config.SCIM"},
|
||||
{Key: "max_key_ttl", Type: "time.Duration", Doc: "MaxKeyTTL is how long any API key may live; a key asked for with no\nexpiry is given this. Organizations, teams and projects can set\nshorter ones (llmgw keys ttl). Zero is no limit.", Nested: ""},
|
||||
{Key: "personal", Type: "Personal", Doc: "Personal is what a person may do on their own, outside any project: make\npersonal keys and use the chat page personally, within these limits.", Nested: "config.Personal"},
|
||||
@@ -188,6 +189,22 @@ var Reference = []Block{
|
||||
{Key: "min_blocks", Type: "int", Doc: "MinBlocks is the fewest leading blocks a replica must hold to be\npreferred for them; fewer is coincidence — a chat template's opening\ntokens — rather than a prefix worth following. Zero takes 4, which is\n64 tokens at vLLM's usual block size of 16.", Nested: ""},
|
||||
{Key: "max_blocks", Type: "int", Doc: "MaxBlocks bounds how many of a prompt's blocks are hashed and looked\nup. Beyond it a longer match still helps, but the difference no longer\ndecides anything. Zero takes 4096.", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "LDAP", Title: "LDAP", Doc: "", Fields: []Field{
|
||||
{Key: "urls", Type: "[]string", Doc: "URLs are the directory's servers, tried in turn: ldaps:// (636), or\nldap:// (389) with StartTLS.", Nested: ""},
|
||||
{Key: "start_tls", Type: "bool", Doc: "StartTLS upgrades an ldap:// connection before anything is sent. A\npassword never crosses a connection without TLS: ldap:// without it\nis refused.", Nested: ""},
|
||||
{Key: "ca_file", Type: "string", Doc: "CAFile is the authority the servers' certificates are checked against,\nwhen it is not the system's: an AD Certificate Services root.", Nested: ""},
|
||||
{Key: "bind_dn", Type: "string", Doc: "BindDN and the file holding its password: a service account that may\nread users and groups. Nothing is written to the directory.", Nested: ""},
|
||||
{Key: "bind_password_file", Type: "string", Doc: "", Nested: ""},
|
||||
{Key: "base_dn", Type: "string", Doc: "BaseDN is where people are searched for, subtree included.", Nested: ""},
|
||||
{Key: "group_base_dn", Type: "string", Doc: "GroupBaseDN is where their groups are; empty is BaseDN's domain root.", Nested: ""},
|
||||
{Key: "user_filter", Type: "string", Doc: "UserFilter narrows who may sign in, ANDed with the login match: a\ngroup (memberOf=...) or an OU's people only. Empty is every enabled\nuser object.", Nested: ""},
|
||||
{Key: "label", Type: "string", Doc: "Label is what the form says to sign in with: \"Sign in with your\n<label> account\". Empty is \"Active Directory\".", Nested: ""},
|
||||
{Key: "create_accounts", Type: "bool", Doc: "CreateAccounts makes an account, active, for a person the directory\nvouches for and the gateway does not know. Off, only existing accounts\nsign in this way.", Nested: ""},
|
||||
{Key: "roles", Type: "map[string][]string", Doc: "Roles maps a staff role (viewer, operator, billing, admin) to the\ngroups that give it, by name (CN) or whole DN. Set at every sign-in\nand sync: someone taken out of the group loses the role.", Nested: ""},
|
||||
{Key: "memberships", Type: "map[string]string", Doc: "Memberships maps a group, by CN or DN, to a place in the tree —\norg:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT — joined at sign-in\nand kept by the sync, which also takes away a place whose group the\nperson has left.", Nested: ""},
|
||||
{Key: "sync_interval", Type: "time.Duration", Doc: "SyncInterval is how often the leader reads the directory. Zero is\n15m; negative turns the sync off, leaving only sign-in.", Nested: ""},
|
||||
{Key: "timeout", Type: "time.Duration", Doc: "Timeout bounds each round trip to a server. Zero is 10s.", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "Landing", Title: "Landing", Doc: "", Fields: []Field{
|
||||
{Key: "contact", Type: "string", Doc: "Contact is where someone without a key should go — an address, a URL, a\nchannel name. Rendered as a link when it is one, as text otherwise.", Nested: ""},
|
||||
{Key: "notice", Type: "string", Doc: "Notice is an optional line of your own, for whatever the reader needs to\nknow that this page cannot guess.", Nested: ""},
|
||||
|
||||
@@ -33,6 +33,7 @@ var Sources = []string{
|
||||
"internal/guard/policy.go",
|
||||
"internal/guard/judge.go",
|
||||
"internal/config/oidc.go",
|
||||
"internal/config/ldap.go",
|
||||
"internal/config/firewall.go",
|
||||
"internal/config/serving.go",
|
||||
"internal/config/presets.go",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/directory"
|
||||
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// Sign-in against a directory (auth.ldap), and the accounts kept in step
|
||||
// with it by the leader.
|
||||
|
||||
// ldapDirectory is auth.ldap as the gateway uses it.
|
||||
type ldapDirectory struct {
|
||||
dir *directory.LDAP
|
||||
keeper *directory.Keeper
|
||||
every time.Duration
|
||||
label string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func newLDAPDirectory(cfg config.LDAP, st *storepkg.DB, log *slog.Logger) (*ldapDirectory, error) {
|
||||
dir, err := directory.NewLDAP(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ldapDirectory{
|
||||
dir: dir,
|
||||
keeper: &directory.Keeper{St: st, Name: "ldap", CreateAccounts: cfg.CreateAccounts,
|
||||
Mapping: directory.Mapping{Roles: cfg.Roles, Memberships: cfg.Memberships}},
|
||||
every: cfg.Interval(),
|
||||
label: cmp.Or(cfg.Label, "Active Directory"),
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SignIn is account.Directory: the person's name and password checked by
|
||||
// the directory, then their account made or kept in step.
|
||||
func (d *ldapDirectory) SignIn(ctx context.Context, login, password string) (storepkg.User, error) {
|
||||
id, err := d.dir.Authenticate(ctx, login, password)
|
||||
switch {
|
||||
case errors.Is(err, directory.ErrBadCredentials):
|
||||
return storepkg.User{}, account.ErrBadCredentials
|
||||
case err != nil:
|
||||
d.log.Warn("the directory could not be asked about a sign-in", "err", err)
|
||||
return storepkg.User{}, fmt.Errorf("the directory could not be asked: %w", err)
|
||||
}
|
||||
u, err := d.keeper.SignIn(ctx, id)
|
||||
switch {
|
||||
case errors.Is(err, directory.ErrSuspended):
|
||||
return storepkg.User{}, account.ErrSuspended
|
||||
case errors.Is(err, directory.ErrNoAccount), errors.Is(err, directory.ErrNoEmail):
|
||||
// Said to the log, not the form: the form says what a wrong
|
||||
// password says.
|
||||
d.log.Info("the directory vouched for a person who has no account here", "dn", id.DN, "err", err)
|
||||
return storepkg.User{}, account.ErrBadCredentials
|
||||
case errors.Is(err, directory.ErrNotActive):
|
||||
return storepkg.User{}, account.ErrNotApproved
|
||||
case err != nil:
|
||||
return storepkg.User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// sync reads the directory every interval, on the leader, and brings the
|
||||
// accounts it keeps in step.
|
||||
func (d *ldapDirectory) sync(ctx context.Context, leader func() bool) {
|
||||
if d.every <= 0 {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(d.every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
if leader() {
|
||||
d.syncOnce(ctx)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *ldapDirectory) syncOnce(ctx context.Context) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
people, err := d.dir.People(ctx, d.keeper.Mapping)
|
||||
if err != nil {
|
||||
d.log.Warn("could not read the directory; accounts left as they are", "err", err)
|
||||
return
|
||||
}
|
||||
rep, err := d.keeper.Sync(ctx, people)
|
||||
if err != nil {
|
||||
d.log.Error("the directory sync stopped", "err", err, "people", len(people))
|
||||
return
|
||||
}
|
||||
for _, where := range rep.Unmapped {
|
||||
d.log.Warn("auth.ldap.memberships maps a group to a place the tree does not have", "place", where)
|
||||
}
|
||||
if rep.Suspended+rep.Reinstated+rep.Updated > 0 {
|
||||
d.log.Info("accounts kept in step with the directory", "people", len(people), "accounts", rep.Seen,
|
||||
"suspended", rep.Suspended, "reinstated", rep.Reinstated, "roles_changed", rep.Updated)
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,9 @@ func (r *Router) Handler() http.Handler {
|
||||
return web.ModelFacts{ContextLength: m.ContextLength, MaxOutput: m.MaxOutput, Tools: m.Capabilities.Tools}
|
||||
})
|
||||
pages.SetPersonalOff(r.personalOff)
|
||||
if r.directory != nil {
|
||||
pages.SetDirectory(r.directory.label)
|
||||
}
|
||||
pages.SetLlamaCppVersion(r.llamaVersion)
|
||||
pages.SetPersonalDefaults(r.personalDefaults)
|
||||
if r.store != nil {
|
||||
|
||||
@@ -119,6 +119,8 @@ type Router struct {
|
||||
acct *accounting.Accountant
|
||||
// notifier says what the operator must act on; nil says nothing.
|
||||
notifier *notify.Notifier
|
||||
// directory is auth.ldap's: sign-in and the accounts kept in step.
|
||||
directory *ldapDirectory
|
||||
// guard judges requests and answers; nil judges nothing. guardLibrary
|
||||
// is the policies it was built from, for the pages that show them.
|
||||
guard guard.Guard
|
||||
@@ -395,6 +397,14 @@ func New(cfg config.Server, log *slog.Logger) (*Router, error) {
|
||||
r.accounts = account.New(st, account.Config{
|
||||
SignupOpen: cfg.Auth.SignupOpen,
|
||||
}, log)
|
||||
if cfg.Auth.LDAP.Enabled() {
|
||||
d, err := newLDAPDirectory(cfg.Auth.LDAP, st, log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.directory = d
|
||||
r.accounts.SetDirectory(d)
|
||||
}
|
||||
|
||||
r.acct = accounting.New(
|
||||
accounting.NewPricing(prices, accounting.Price{
|
||||
@@ -535,6 +545,9 @@ func (r *Router) Start(ctx context.Context) {
|
||||
if r.threads != nil {
|
||||
go r.pruneThreads(ctx)
|
||||
}
|
||||
if r.directory != nil {
|
||||
go r.directory.sync(ctx, r.leading)
|
||||
}
|
||||
if r.store != nil && r.usageRetention > 0 {
|
||||
go r.pruneUsage(ctx, r.usageRetention)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ var copyOrder = []string{
|
||||
"memberships",
|
||||
// After users, which it points at.
|
||||
"user_excluded_licences",
|
||||
// After users, which it points at.
|
||||
"user_directory",
|
||||
"preset_state",
|
||||
"api_keys",
|
||||
"key_claims",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DirectoryLink is an account kept by an outside directory: which one, the
|
||||
// account's id there, and whether the directory's sync is what suspended it.
|
||||
type DirectoryLink struct {
|
||||
UserID string
|
||||
Directory string // "ldap"
|
||||
ExternalID string // an AD objectGUID
|
||||
// SuspendedBySync is an account suspended because the directory
|
||||
// disabled or lost it, and so reinstated when the directory has it
|
||||
// enabled again. One an administrator suspended is not.
|
||||
SuspendedBySync bool
|
||||
SyncedAt time.Time
|
||||
}
|
||||
|
||||
// DirectoryUser is the account a directory knows by that id.
|
||||
func (s *DB) DirectoryUser(ctx context.Context, directory, externalID string) (DirectoryLink, error) {
|
||||
var l DirectoryLink
|
||||
var suspended int
|
||||
var synced int64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT user_id, directory, external_id, suspended_by_sync, synced_at FROM user_directory
|
||||
WHERE directory = ? AND external_id = ?`, directory, externalID).
|
||||
Scan(&l.UserID, &l.Directory, &l.ExternalID, &suspended, &synced)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DirectoryLink{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return DirectoryLink{}, err
|
||||
}
|
||||
l.SuspendedBySync, l.SyncedAt = suspended != 0, directoryTime(synced)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// LinkDirectory records that a directory keeps an account, by its id there.
|
||||
func (s *DB) LinkDirectory(ctx context.Context, userID, directory, externalID string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO user_directory (user_id, directory, external_id, synced_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET directory = excluded.directory, external_id = excluded.external_id,
|
||||
synced_at = excluded.synced_at`,
|
||||
userID, directory, externalID, at.UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
// DirectoryLinks are every account a directory keeps.
|
||||
func (s *DB) DirectoryLinks(ctx context.Context, directory string) ([]DirectoryLink, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT user_id, directory, external_id, suspended_by_sync, synced_at FROM user_directory WHERE directory = ?`,
|
||||
directory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []DirectoryLink
|
||||
for rows.Next() {
|
||||
var l DirectoryLink
|
||||
var suspended int
|
||||
var synced int64
|
||||
if err := rows.Scan(&l.UserID, &l.Directory, &l.ExternalID, &suspended, &synced); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.SuspendedBySync, l.SyncedAt = suspended != 0, directoryTime(synced)
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetSyncSuspended says whether the directory's sync is what has an account
|
||||
// suspended, and when it last looked.
|
||||
func (s *DB) SetSyncSuspended(ctx context.Context, userID string, suspended bool, at time.Time) error {
|
||||
v := 0
|
||||
if suspended {
|
||||
v = 1
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE user_directory SET suspended_by_sync = ?, synced_at = ? WHERE user_id = ?`, v, at.UnixMilli(), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func directoryTime(ms int64) time.Time {
|
||||
if ms == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.UnixMilli(ms)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Ported from the SQLite migration of the same number.
|
||||
-- Which directory keeps an account, and its id there: an Active Directory
|
||||
-- objectGUID, so a person whose mail changes there is still the same account
|
||||
-- here. suspended_by_sync is an account the directory sync suspended,
|
||||
-- because it was disabled or gone there; one an administrator suspended
|
||||
-- here is not, and is never reinstated by the sync.
|
||||
CREATE TABLE user_directory (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
directory TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
suspended_by_sync INTEGER NOT NULL DEFAULT 0,
|
||||
synced_at BIGINT NOT NULL DEFAULT 0,
|
||||
UNIQUE (directory, external_id)
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Which directory keeps an account, and its id there: an Active Directory
|
||||
-- objectGUID, so a person whose mail changes there is still the same account
|
||||
-- here. suspended_by_sync is an account the directory sync suspended,
|
||||
-- because it was disabled or gone there; one an administrator suspended
|
||||
-- here is not, and is never reinstated by the sync.
|
||||
CREATE TABLE user_directory (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
directory TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL,
|
||||
suspended_by_sync INTEGER NOT NULL DEFAULT 0,
|
||||
synced_at INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE (directory, external_id)
|
||||
);
|
||||
@@ -179,10 +179,17 @@ func (s *DB) ApproveUser(ctx context.Context, id, projectID, by string, at time.
|
||||
// signup suspended while pending and then reinstated would become active
|
||||
// without anybody approving it.
|
||||
func (s *DB) SetUserState(ctx context.Context, id string, state UserState) error {
|
||||
// An account nobody approved is approved, not set active: that is what
|
||||
// records who let it in. One an identity provider, SCIM or a directory
|
||||
// made — active from the start, its password kept there ("!oidc",
|
||||
// "!scim", "!ldap"), with no approval to record — is set active again
|
||||
// when its provider reinstates it; refusing that left such accounts
|
||||
// suspended for good.
|
||||
if state == UserActive {
|
||||
var approved sql.NullInt64
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT approved_at FROM users WHERE id = ?`, id).
|
||||
Scan(&approved); err == nil && !approved.Valid {
|
||||
var hash string
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT approved_at, password_hash FROM users WHERE id = ?`, id).
|
||||
Scan(&approved, &hash); err == nil && !approved.Valid && !strings.HasPrefix(hash, "!") {
|
||||
return errors.New("this account was never approved; approve it instead")
|
||||
}
|
||||
}
|
||||
|
||||
+24
-7
@@ -86,6 +86,9 @@ type AccountPages struct {
|
||||
hasQuotas bool
|
||||
// oidc is sign-in through an identity provider; nil when there is none.
|
||||
oidc *oidcLogin
|
||||
// directory is the label of the directory that checks passwords
|
||||
// (auth.ldap); empty when there is none.
|
||||
directory string
|
||||
// audit records every change made through these pages; nil records none.
|
||||
audit audit.Sink
|
||||
// hasGuard is set when /admin/guard is registered, and guardLibrary
|
||||
@@ -250,6 +253,10 @@ func (p *AccountPages) signup(w http.ResponseWriter, r *http.Request) {
|
||||
// --- sign in and out ---
|
||||
|
||||
// ssoLabel is what the sign-in button names, or "" without one.
|
||||
// SetDirectory says passwords are also checked by a directory, under this
|
||||
// label, so the sign-in form takes a username as well as a mail.
|
||||
func (p *AccountPages) SetDirectory(label string) { p.directory = label }
|
||||
|
||||
func (p *AccountPages) ssoLabel(l *Lang) string {
|
||||
if p.oidc == nil {
|
||||
return ""
|
||||
@@ -265,13 +272,22 @@ type loginData struct {
|
||||
Version string
|
||||
// SSO is the identity provider's label, when sign-in through one is on.
|
||||
SSO string
|
||||
// Directory is the directory's label when passwords are checked by one
|
||||
// (auth.ldap): the field then takes a username too, not only a mail.
|
||||
Directory string
|
||||
// CSRF is the form's token, matched by a cookie.
|
||||
CSRF string
|
||||
}
|
||||
|
||||
// loginPage is what every rendering of the sign-in form carries.
|
||||
func (p *AccountPages) loginPage(w http.ResponseWriter, r *http.Request, l *Lang, email string) loginData {
|
||||
return loginData{SSO: p.ssoLabel(l), Directory: p.directory, Email: email, SignupOpen: p.accounts.SignupOpen(),
|
||||
Forgot: p.resets != nil, Version: p.version, CSRF: setLoginToken(w, r)}
|
||||
}
|
||||
|
||||
func (p *AccountPages) loginForm(w http.ResponseWriter, r *http.Request) {
|
||||
noStore(w)
|
||||
render(w, r, "login", loginData{SSO: p.ssoLabel(langOf(r)), SignupOpen: p.accounts.SignupOpen(), Forgot: p.resets != nil, Version: p.version, CSRF: setLoginToken(w, r)})
|
||||
render(w, r, "login", p.loginPage(w, r, langOf(r), ""))
|
||||
}
|
||||
|
||||
func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -281,15 +297,16 @@ func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) {
|
||||
if !loginTokenOK(r) {
|
||||
// Not this site's form: another one posting a visitor into an
|
||||
// account of its choosing, or a form left open past its hour.
|
||||
renderStatus(w, r, http.StatusBadRequest, "login", loginData{SSO: p.ssoLabel(l), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version,
|
||||
Error: l.T("login.err.form_expired"), CSRF: setLoginToken(w, r)})
|
||||
d := p.loginPage(w, r, l, email)
|
||||
d.Error = l.T("login.err.form_expired")
|
||||
renderStatus(w, r, http.StatusBadRequest, "login", d)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := account.WithClient(r.Context(), clientAddr(r))
|
||||
token, csrf, err := p.accounts.SignIn(ctx, email, r.FormValue("password"))
|
||||
if err != nil {
|
||||
d := loginData{SSO: p.ssoLabel(l), Email: email, SignupOpen: p.accounts.SignupOpen(), Forgot: p.resets != nil, Version: p.version, CSRF: setLoginToken(w, r)}
|
||||
d := p.loginPage(w, r, l, email)
|
||||
switch {
|
||||
case errors.Is(err, account.ErrBadCredentials),
|
||||
errors.Is(err, account.ErrNotApproved),
|
||||
@@ -309,9 +326,9 @@ func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) {
|
||||
if p.oidc != nil && p.oidc.cfg.NoPasswords {
|
||||
if _, u, err := p.accounts.Resolve(ctx, token); err != nil || !u.IsAdmin {
|
||||
_ = p.accounts.SignOut(ctx, token)
|
||||
renderStatus(w, r, http.StatusForbidden, "login", loginData{SSO: p.ssoLabel(l), Email: email,
|
||||
SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r),
|
||||
Error: l.T("login.err.passwords_off", p.ssoLabel(l))})
|
||||
d := p.loginPage(w, r, l, email)
|
||||
d.Error = l.T("login.err.passwords_off", p.ssoLabel(l))
|
||||
renderStatus(w, r, http.StatusForbidden, "login", d)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"login.title": "Sign in · llmgw",
|
||||
"login.heading": "Sign in",
|
||||
"login.email": "Email",
|
||||
"login.username": "Username or email",
|
||||
"login.with_directory": "Sign in with your %s account.",
|
||||
"login.password": "Password",
|
||||
"login.submit": "Sign in",
|
||||
"login.with_sso": "Sign in with %s",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"login.title": "Giriş yap · llmgw",
|
||||
"login.heading": "Giriş yapın",
|
||||
"login.email": "E-posta",
|
||||
"login.username": "Kullanıcı adı veya e-posta",
|
||||
"login.with_directory": "%s hesabınızla giriş yapın.",
|
||||
"login.password": "Parola",
|
||||
"login.submit": "Giriş yap",
|
||||
"login.with_sso": "%s ile giriş yap",
|
||||
|
||||
+33
-5
@@ -148,8 +148,9 @@ func (p *AccountPages) oidcStart(w http.ResponseWriter, r *http.Request) {
|
||||
pr, err := o.providerFor(r.Context())
|
||||
if err != nil {
|
||||
p.log.Warn("the identity provider did not answer", "issuer", o.cfg.Issuer, "err", err)
|
||||
renderStatus(w, r, http.StatusBadGateway, "login", loginData{SignupOpen: p.accounts.SignupOpen(), Version: p.version,
|
||||
CSRF: setLoginToken(w, r), SSO: o.cfg.Label, Error: langOf(r).T("sso.err.no_answer")})
|
||||
d := p.loginPage(w, r, langOf(r), "")
|
||||
d.Error = langOf(r).T("sso.err.no_answer")
|
||||
renderStatus(w, r, http.StatusBadGateway, "login", d)
|
||||
return
|
||||
}
|
||||
state, nonce, verifier := randomToken(), randomToken(), oauth2.GenerateVerifier()
|
||||
@@ -172,8 +173,9 @@ func (p *AccountPages) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if errors.As(err, &ae) {
|
||||
msg = ae.in(l)
|
||||
}
|
||||
renderStatus(w, r, http.StatusForbidden, "login", loginData{SignupOpen: p.accounts.SignupOpen(),
|
||||
Version: p.version, CSRF: setLoginToken(w, r), SSO: o.cfg.Label, Error: msg})
|
||||
d := p.loginPage(w, r, l, "")
|
||||
d.Error = msg
|
||||
renderStatus(w, r, http.StatusForbidden, "login", d)
|
||||
}
|
||||
c, err := r.Cookie(oidcCookie)
|
||||
if err != nil {
|
||||
@@ -297,7 +299,17 @@ func (p *AccountPages) oidcUser(ctx context.Context, claims map[string]any) (sto
|
||||
return store.User{}, accountError{"sso.err.state", []any{email, string(u.State)}}
|
||||
}
|
||||
|
||||
groups := claimStrings(claims[or(o.cfg.GroupsClaim, "groups")])
|
||||
claim := or(o.cfg.GroupsClaim, "groups")
|
||||
if groupsOverflowed(claims, claim) {
|
||||
// Read as no groups, this would take an administrator's role away
|
||||
// at their next sign-in: kept as they are instead, and said.
|
||||
p.log.Error("the identity provider left this person's groups out of the token, as Entra ID does past 200 groups; "+
|
||||
"their role and memberships are left as they were. In the app registration's token configuration, "+
|
||||
"emit only the groups assigned to the application, or provision through SCIM",
|
||||
"email", email, "claim", claim)
|
||||
return u, nil
|
||||
}
|
||||
groups := claimStrings(claims[claim])
|
||||
if len(o.cfg.Roles) > 0 {
|
||||
role := ""
|
||||
for _, r := range []string{store.RoleAdmin, store.RoleOperator, store.RoleBilling, store.RoleViewer} {
|
||||
@@ -333,6 +345,22 @@ func (p *AccountPages) oidcUser(ctx context.Context, claims map[string]any) (sto
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// groupsOverflowed is a token whose groups claim was too long to send:
|
||||
// Entra ID then names a source to fetch it from (_claim_names), or, in a
|
||||
// token from the implicit flow, says only hasgroups.
|
||||
func groupsOverflowed(claims map[string]any, claim string) bool {
|
||||
if _, ok := claims[claim]; ok {
|
||||
return false
|
||||
}
|
||||
if names, ok := claims["_claim_names"].(map[string]any); ok {
|
||||
if _, ok := names[claim]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
has, _ := claims["hasgroups"].(bool)
|
||||
return has
|
||||
}
|
||||
|
||||
// oidcPlace finds org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT.
|
||||
func (p *AccountPages) oidcPlace(ctx context.Context, where string) (store.Scope, string, error) {
|
||||
kind, path, _ := strings.Cut(where, ":")
|
||||
|
||||
@@ -165,6 +165,15 @@ func TestSigningInThroughAProvider(t *testing.T) {
|
||||
t.Error("no session cookie")
|
||||
}
|
||||
|
||||
// Past 200 groups Entra ID sends none, only where to fetch them: the
|
||||
// role stays rather than being taken away as if she had no groups.
|
||||
idp.claims = map[string]any{"email": "ann@example.com", "email_verified": true,
|
||||
"_claim_names": map[string]any{"groups": "src1"},
|
||||
"_claim_sources": map[string]any{"src1": map[string]any{"endpoint": "https://graph.windows.net/t/users/u/getMemberObjects"}}}
|
||||
if rec := signIn(t, mux, idp, false); rec.Code != http.StatusSeeOther || st.roles[u.ID] != "operator" {
|
||||
t.Errorf("an overflowed groups claim: %d, role %q", rec.Code, st.roles[u.ID])
|
||||
}
|
||||
|
||||
// Refused: a state that is not this browser's, an unverified address,
|
||||
// and nobody the gateway knows when it does not make accounts.
|
||||
if rec := signIn(t, mux, idp, true); rec.Code != http.StatusForbidden {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" autocomplete="on">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>{{t "login.email"}}<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
|
||||
{{if .Directory}}<p class="sub">{{t "login.with_directory" .Directory}}</p>
|
||||
<label>{{t "login.username"}}<input type="text" name="email" value="{{.Email}}" required autocomplete="username" autocapitalize="none" spellcheck="false"></label>
|
||||
{{else}}<label>{{t "login.email"}}<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>{{end}}
|
||||
<label>{{t "login.password"}}<input type="password" name="password" required autocomplete="current-password"></label>
|
||||
<button type="submit">{{t "login.submit"}}</button>
|
||||
</form>
|
||||
|
||||
Reference in new issue
Block a user