From f27d72211143575176f786ab07bc35b9eff0b437 Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Sun, 18 Oct 2026 10:59:50 +0200 Subject: [PATCH] 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) --- BACKLOG.md | 21 +- CHANGELOG.md | 12 + docs/install.md | 76 ++++ docs/reference.md | 124 ++++++ go.mod | 3 + go.sum | 22 + internal/account/directory_test.go | 63 +++ internal/account/service.go | 37 +- internal/config/config.go | 9 + internal/config/ldap.go | 137 ++++++ internal/config/oidc.go | 23 +- internal/directory/directory.go | 395 ++++++++++++++++++ internal/directory/directory_test.go | 142 +++++++ internal/directory/ldap.go | 286 +++++++++++++ internal/directory/ldap_test.go | 130 ++++++ internal/docgen/reference_gen.go | 17 + internal/docgen/render.go | 1 + internal/router/directory.go | 112 +++++ internal/router/handlers.go | 3 + internal/router/router.go | 13 + internal/store/copy.go | 2 + internal/store/directory.go | 92 ++++ .../postgres/0048_user_directory.sql | 14 + .../migrations/sqlite/0048_user_directory.sql | 13 + internal/store/users.go | 11 +- internal/web/account.go | 31 +- internal/web/locales/en/account.json | 2 + internal/web/locales/tr/account.json | 2 + internal/web/oidc.go | 38 +- internal/web/oidc_test.go | 9 + internal/web/templates/account.html | 4 +- 31 files changed, 1815 insertions(+), 29 deletions(-) create mode 100644 internal/account/directory_test.go create mode 100644 internal/config/ldap.go create mode 100644 internal/directory/directory.go create mode 100644 internal/directory/directory_test.go create mode 100644 internal/directory/ldap.go create mode 100644 internal/directory/ldap_test.go create mode 100644 internal/router/directory.go create mode 100644 internal/store/directory.go create mode 100644 internal/store/migrations/postgres/0048_user_directory.sql create mode 100644 internal/store/migrations/sqlite/0048_user_directory.sql diff --git a/BACKLOG.md b/BACKLOG.md index 700ba11..406b239 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6a4e7..72b3663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/install.md b/docs/install.md index ffa72ba..ebc19dd 100644 --- a/docs/install.md +++ b/docs/install.md @@ -2541,6 +2541,82 @@ and point it at `https:///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:///login/oidc/callback`. The issuer is + `https://login.microsoftonline.com//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:///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`, diff --git a/docs/reference.md b/docs/reference.md index 9e1a0f1..859c950 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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 +