# Directory sign-in and groups A company that runs Active Directory, OpenLDAP or FreeIPA already knows who works there and which teams they belong to. This page covers the three ways AgenticOS can use that knowledge. People can sign in with their directory account. A domain-joined browser can sign them in with its Kerberos ticket. And directory groups can decide which organizations people join, with which role, and which groups they are in. Three pieces make that up, and they work independently: | Piece | What it does | Configured by | |---|---|---| | [Groups](#groups) | Named sets of an organization's members that a resource can be shared with | Members with `members:manage`, in the console | | [Directory group mappings](#directory-group-mappings) | "Everyone in this directory group joins as this role, in this group" | Members with `members:manage` and `roles:manage` | | [Directory sign-in](#signing-in-with-a-directory-account) | LDAP password sign-in, and Kerberos (SPNEGO) sign-in | The operator, in `backend/.env` | The mappings also apply to people who sign in through [OIDC](configuration.md#single-sign-on-generic-oidc), when the identity provider reports their groups. If your company already runs Keycloak or Entra ID in front of its directory, you may not need the native LDAP sign-in at all. See [Through an identity provider instead](#through-an-identity-provider-instead). ## Groups A group is a named set of members inside one organization: *Finance*, *Support team*, *Platform admins*. You share an agent, a skill, a collection, a context file, a vault secret or an app with a group the same way you share it with a person, from the resource's **Sharing** panel. One grant then reaches everyone in the group. A group grant reaches whoever is in the group **when access is checked**. Somebody who joins the group next month reaches the resource; somebody who leaves stops reaching it. There is nothing to revoke per person. A group carries no role. What a member may do in the organization is still their membership's role, and a group only adds the grants made to it. Effective access stays the rule described in [Permissions](permissions.md#how-the-layers-combine): ``` effective access to one row = max(role scope, the person's grant, their groups' grants) ``` When a person reaches a row through several grants, the highest level wins. Read through their own grant and edit through their group's is edit. | Action | Who may | |---|---| | List groups and see who is in them | Any member of the organization | | Create, rename or delete a group; add or remove people | `members:manage` | | Share a resource with a group | Whoever may edit that resource | Groups live on **Organizations → Members → Groups**. Only members of the organization can be added, and removing somebody from the organization removes them from all of its groups. Deleting a group deletes every grant made to it and every directory mapping that names it, so it takes the authority deleting those mappings takes: an Admin cannot delete a group an Admin-level mapping points at. ## Directory group mappings A mapping says what one directory group means inside one organization: > Everyone in `CN=Finance,OU=Groups,DC=corp,DC=example,DC=com` is a **member** > here, and is in the group **Finance**. Mappings live on **Organizations → Members → Directory**. Reading them takes `members:manage`. Creating or deleting one takes `members:manage` and `roles:manage`, because a mapping both admits people and gives them a role. The role is bounded the same way an invitation's is. You can only map a directory group to a role your own role strictly outranks. Nobody can map a group to `owner`, and an Admin cannot map a group to Admin. Deleting a mapping takes the same authority, because it demotes everybody it placed. ### What happens at sign-in Every time somebody signs in with LDAP, with Kerberos, or through OIDC with [`OIDC_GROUPS_CLAIM`](#the-groups-claim-over-oidc) set, their directory groups are read against every organization's mappings: 1. **An organization with a matching mapping** is joined if they are not a member yet, with the mapped role. The membership is marked **directory**. 2. **A directory membership** gets its role brought up to date with the mappings. 3. **They join the mapped groups**, and leave the directory-made group memberships no mapping names any more. 4. **A directory membership with no matching mapping left** is removed, together with the person's group memberships in that organization. Groups are compared without regard to case or surrounding spaces, because every directory compares them that way. When several mappings match in one organization, the role is the first of these that any of them names: **admin → builder → operator → member → viewer** Roles are not ordered by one line of authority. A builder and an operator each hold something the other does not, so the order is stated here rather than worked out. ### What the sync never touches Automatic membership reconciliation respects these four rules: - **A membership an administrator made keeps its role.** Somebody invited by hand keeps the role they were given, even if a mapping matches them. They still join the mapped groups. - **Changing a directory member's role by hand takes the membership over.** It becomes a manual membership, and later sign-ins leave its role alone. - **A group membership added by hand stays** when the directory would take the person out of that group. - **An owner is never demoted or removed**, and no mapping can make one. Ownership moves through **Transfer ownership** only. A personal organization is never joined through a mapping, and a mapping cannot be created for one. When both a directory mapping and a [Discord role mapping](channels.md#discord-roles) admit somebody, the directory takes priority at sign-in: it takes over a Discord-managed membership with the directory's role. Losing the Discord role then cannot remove that person. When the directory grant ends, sign-in checks whether a linked Discord account still grants membership before removing it. A remaining grant transfers the same row to **Discord**, preserving API keys, connected-app consent and unrelated group memberships. No remaining grant removes the membership normally. An unreadable Discord admission mapping, including the ten-second network budget expiring, hands the retained membership to Discord so the periodic sweep retries without another sign-in. Its role is limited to authority justified by both the prior role and a possible unread grant. All organizations checked during one sign-in share that network deadline. Manual memberships and owners are never taken over. The handovers are audited as `directory.member_taken_over` and `directory.member_handed_to_discord`. Group memberships retain their own source and reconciliation rules. !!! warning "Deleting a mapping removes the people it placed" Removal happens at each person's **next sign-in**, not at once. To keep somebody after you delete the mapping that brought them in, change their role by hand first. That takes the membership over. ### Mappings admit new accounts A mapping counts as an invitation. On an `invite_only` deployment, or one with an allowed-domains list, a first sign-in whose groups match any organization's mapping creates the account. Somebody who holds both `members:manage` and `roles:manage` decided that everyone in that group belongs. A `closed` deployment still refuses it. See [Who may register](deployment.md#who-may-register). ## Signing in with a directory account Set `LDAP_URL` and the sign-in page offers a directory form. People type the username they use everywhere else and their directory password. AgenticOS checks the password with the standard two-step bind: 1. It searches `LDAP_USER_BASE_DN` with `LDAP_USER_FILTER`, using the service account (`LDAP_BIND_DN`), for exactly one account matching the username. 2. It binds **as that account** with the typed password. Only the directory checks the password. It is not stored, hashed or logged here. The account is keyed on the directory's own stable identifier: `LDAP_ID_ATTRIBUTE`, `objectGUID` on Active Directory and `entryUUID` on OpenLDAP and FreeIPA. It is not keyed on the address, so a renamed person keeps their history. On their first sign-in the account is created under the [sign-up policy](deployment.md#who-may-register), and an existing account with the same address is linked to it. ```bash # backend/.env - Active Directory over LDAPS LDAP_URL=ldaps://dc01.corp.example.com LDAP_CA_CERT_FILE=/etc/ssl/certs/corp-ca.pem LDAP_BIND_DN=CN=agenticos,OU=Service Accounts,DC=corp,DC=example,DC=com LDAP_BIND_PASSWORD=... LDAP_USER_BASE_DN=OU=Staff,DC=corp,DC=example,DC=com LDAP_ID_ATTRIBUTE=objectGUID ``` ```bash # frontend - show the form on the sign-in page OAUTH_PROVIDERS=ldap LDAP_DISPLAY_NAME=Active Directory ``` Every setting and its default is in [Configuration](configuration.md#directory-sign-in-ldap). ### What the sign-in refuses | Refused | Why | |---|---| | An empty password | An LDAP bind with a name and no password is an *unauthenticated* bind, which many servers accept (RFC 4513). It never reaches the directory | | Two accounts matching one username | Binding as whichever came back first could sign one person in as another | | A username carrying filter syntax | It is escaped (RFC 4515), so `*` finds nobody rather than everybody | | An account with no address or no identifier | The account, the invitation and the sign-up policy are all keyed on them | | `ldap://` without StartTLS | It sends every password in the clear. The process refuses to start unless `LDAP_ALLOW_PLAINTEXT=true` says so on purpose | A wrong username and a wrong password get the same answer. A directory that is down, a refused service account and a search the directory could not answer (a wrong base DN, say) are reported as the directory being unavailable. They are not reported as a wrong password, which would send every person in the company to reset theirs. The certificate is verified against the system trust store, or against `LDAP_CA_CERT_FILE` for a company CA, and so is the host name. The sign-in is rate-limited per address and per username, like the password sign-in. ### Groups from the directory By default a person's groups are the DNs in their `memberOf` attribute. A directory without `memberOf` can be searched instead: set `LDAP_GROUP_BASE_DN`, and `LDAP_GROUP_FILTER` with `{dn}` or `{username}` in it. For Active Directory's nested groups, search with the matching rule that follows the chain: ```bash LDAP_GROUP_BASE_DN=OU=Groups,DC=corp,DC=example,DC=com LDAP_GROUP_FILTER=(member:1.2.840.113556.1.4.1941:={dn}) ``` To let only one group sign in at all, put it in `LDAP_USER_FILTER`: `(&(objectClass=user)(sAMAccountName={username})(memberOf=CN=AgenticOS Users,OU=Groups,DC=corp,DC=example,DC=com))`. ## Integrated Windows sign-in (Kerberos) On a domain-joined machine, a browser pointed at a host its policy trusts answers `WWW-Authenticate: Negotiate` with a Kerberos ticket. With `KERBEROS_ENABLED=true` the sign-in page offers a button that signs the person in with that ticket, and nobody types a password. The ticket names a principal, `jane@CORP.EXAMPLE.COM`. AgenticOS resolves it through the directory above, with `LDAP_KERBEROS_FILTER` (`(userPrincipalName={principal})` by default), to the **same account** the LDAP password sign-in reaches. So Kerberos needs `LDAP_URL` as well, and the directory decides the address and the groups the same way for both. What it takes: 1. **An image built with the `kerberos` extra.** `gssapi` compiles against the system Kerberos libraries, so it is not in the default image: `docker build --build-arg EXTRAS="--extra kerberos" -f backend/Dockerfile .` 2. **A service principal and its keytab.** On Active Directory, create a service account, give it the SPN `HTTP/agenticos.corp.example.com`, and export a keytab with `ktpass`. Mount it into the backend container. 3. **The backend settings.** ```bash KERBEROS_ENABLED=true KERBEROS_KEYTAB=/etc/agenticos/http.keytab KERBEROS_SERVICE_PRINCIPAL=HTTP/agenticos.corp.example.com@CORP.EXAMPLE.COM ``` 4. **The button**, with `kerberos` in the frontend's `OAUTH_PROVIDERS` and `KERBEROS_DISPLAY_NAME=Windows sign-in` if you want it named that way. 5. **Browser policy** that lets the browser negotiate with the API host. That means the local intranet zone on Windows, or `AuthServerAllowlist` for Chrome and Edge, or `network.negotiate-auth.trusted-uris` for Firefox. The service principal must name the host the browser reaches the **API** on. That is `PUBLIC_API_URL`, because the negotiation happens there, not on the console's host. A browser that cannot answer the challenge is sent straight back to the sign-in page with a sentence saying so. That covers any machine outside the domain, and any browser whose policy does not list the host. Only a ticket accepted in one step is accepted at all. NTLM is not offered. ## Through an identity provider instead If your company already puts an identity provider in front of its directory, point AgenticOS at that instead, over [OIDC](configuration.md#single-sign-on-generic-oidc). Use its groups claim for the mappings. The native LDAP and Kerberos sign-in are then not needed. This is also the answer for multi-factor authentication, which the native sign-in does not do. **Keycloak** federates both. Under **User federation**, add an **LDAP** provider for your directory, and switch on **Allow Kerberos authentication** there if you want integrated sign-in. Its **group-ldap-mapper** brings the directory's groups across. Then give the AgenticOS client a **Group Membership** mapper whose token claim is `groups`, and set: ```bash OIDC_ISSUER=https://id.corp.example.com/realms/staff OIDC_GROUPS_CLAIM=groups ``` The mapping's directory group is then what Keycloak puts in the claim: the group path, such as `/Finance`, or the bare name if **Full group path** is off. **Microsoft Entra ID**, with groups synchronized from Active Directory, puts a `groups` claim in the token once the app registration has one under **Token configuration**. By default it carries each group's object id, and that id is what you map. ### The groups claim over OIDC `OIDC_GROUPS_CLAIM` names the claim that lists a person's groups. With it unset, OIDC sign-ins leave memberships alone. With it set, each sign-in of the deployment's own provider applies the mappings exactly as a directory sign-in does. Google sign-in never does, because only the deployment's own provider is trusted to say which of its groups somebody is in. The claim is read from the ID token, or from the UserInfo endpoint when the token does not carry it. A configured claim that is absent counts as **no groups**. Keycloak and Okta leave an empty list out rather than send one, and somebody removed from their last group has to lose what it gave them. If the token lacks the claim and UserInfo cannot be reached, the sign-in is refused instead: that says nothing about the person's groups, and reading it as none would strip them. `OIDC_GROUPS_CLAIM` and `LDAP_URL` cannot be set together. Each reports groups in its own identifiers, and each sign-in would take away the memberships the other made, so the process refuses to start with both. !!! warning "Entra ID group overage is refused" Past 200 groups, Entra ID sends a pointer to Microsoft Graph instead of the groups (`_claim_names`). Such a token does not say which groups the person is in, so the sign-in is refused with a sentence asking for the fix. Reading it as "no groups" would strip every membership the directory gave them, and ignoring it would keep ones it may have taken away. Configure the app registration to send **groups assigned to the application**. ## What this does not do yet - **Nothing re-checks the directory between sign-ins.** Disabling an account in the directory stops its next sign-in, and the next sign-in is when mappings are re-read. A session already open lasts until its refresh token expires, which is `REFRESH_TOKEN_EXPIRE_MINUTES` and seven days by default. To end one sooner, deactivate the account under **Admin → Users**, which refuses it everywhere at once. There is no SCIM and no scheduled directory sync. - **A directory account can still use the password reset and magic link.** They go to the account's address, like every other account's. If the directory is meant to be the only way in, keep those addresses under the company's own control, as you would for OIDC accounts. - **No SAML.** A SAML-only identity provider can reach AgenticOS through Keycloak, which speaks SAML to it and OIDC to AgenticOS.