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 +