# LDAPDirectory

> Reference for the nauthera.io/v1alpha1 LDAPDirectory resource, which lets an organization's users sign in with their LDAP or Active Directory password.

An `LDAPDirectory` connects an organization to its LDAP directory or Active Directory.
A client that opts in lets the directory's users sign in on the hosted sign-in page with
their directory username and password. The [operator](https://www.nauthera.io/docs/v1/operator) reads the service
account's password from a Secret and the CA bundle from a ConfigMap or Secret, and sends
both with the spec to the server's gRPC `AdminService` (`UpsertLDAPDirectory`). The server
stores the password sealed and never returns it.

| Property | Value |
|---|---|
| API group / version | `nauthera.io/v1alpha1` |
| Kind | `LDAPDirectory` |
| Plural / short name | `ldapdirectories` / `ldap` |
| Scope | Namespaced |
| Definition | `deploy/crds/ldapdirectory-crd.yaml` in nauthera-server |

The server only reads the directory. Passwords are set, reset and expired there, never
here, and no directory is offered to a client by default.

## Example

Put the service account's password and the directory's CA certificate beside the
resource. Use `--from-literal` or `printf '%s'`, not `echo`, so the password has no
trailing newline:

```bash
kubectl -n nauthera create secret generic corp-ldap \
  --from-literal=password=<the service account's password>
kubectl -n nauthera create configmap corp-ca --from-file=ca.crt=corp-ca.pem
```

```yaml
apiVersion: nauthera.io/v1alpha1
kind: LDAPDirectory
metadata:
  name: corp
  namespace: nauthera
spec:
  url: ldaps://dc1.corp.example:636       # or ldap://, always upgraded with StartTLS
  caBundleRef:                            # optional; the system roots when unset
    kind: ConfigMap                       # the default; or Secret
    name: corp-ca
    key: ca.crt                           # the default
  bind:                                   # optional; searched anonymously when unset
    dn: CN=svc-nauthera,OU=Service Accounts,DC=corp,DC=example
    passwordSecretRef:
      name: corp-ldap
      key: password                       # the default
  userBaseDn: OU=People,DC=corp,DC=example
  userFilter: (&(objectCategory=person)(objectClass=user))   # optional
  usernameAttribute: sAMAccountName       # uid for OpenLDAP
  deletionPolicy: Retain
```

A client then offers the directory in its own spec, by slug:

```yaml
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: intranet
  namespace: nauthera
spec:
  clientId: intranet
  # …
  ldapDirectories:
    - slug: corp
      allowSignIn: true
      allowSignUp: true
```

`kubectl get ldapdirectories` shows the slug, the URL, the phase and the `Ready`
condition's status and reason; `-o wide` adds the message.

## Before you apply one

- The operator's identity needs the `ldap_directories` operation, and the organization
  and namespace, in its `server.grpc.tls.allowed_clients` entry. The Helm chart's default
  entry (`certManager.grpcTLS.allowedClients`) grants it in the main organization; add a
  tenant's slug for directories there.
- The server needs network access to the directory's host and port.
- The service account needs to search `userBaseDn` and read the attributes sign-in uses:
  `usernameAttribute`, `mail`, `objectGUID` (Active Directory) or `entryUUID` (OpenLDAP),
  and on Active Directory `userAccountControl`, `msDS-User-Account-Control-Computed`,
  `accountExpires` and `pwdLastSet`. A read-only account is enough.

## The connection

- **No connection binds in cleartext.** The scheme of `url` is the TLS mode: `ldaps://`
  is TLS from the first byte, and `ldap://` is upgraded with StartTLS (RFC 4511 §4.14)
  before anything else is sent. The URL names a host and port only; the base DN is
  `userBaseDn`.
- **The directory's certificate** must chain to the PEM certificates in `caBundleRef`,
  or to the system roots when it is unset, and be issued for the URL's host.
- **The service account** in `bind` is what the server searches as. Without `bind` it
  searches anonymously.
- **Read-only.** `readOnly` defaults to `true`, and `true` is the only value the server
  accepts: the server never writes to the directory.

Every five minutes the server connects to each directory as sign-in will and records
whether it answered. A change is logged as `ldap directory reachability changed`, with the
directory's `issuer`, `slug` and `state`: `reachable`, `unreachable`, `tls_failed`,
`service_bind_refused`, or `password_unavailable` (the stored bind password does not open
with the server's current keys). The admin gRPC API reports the same in the directory's
reachability status. A broken connection shows there before a user meets it.

## How sign-in works

When a client lists directories, its sign-in page asks for **Username or email** instead
of an email address, and leaves out **Forgot password?**. A password sign-in then goes
like this:

1. Each directory the client lists is asked, in order, for an entry under `userBaseDn`
   that matches `userFilter` and whose `usernameAttribute` is the identifier typed.
2. The first directory that holds the identifier decides. The server binds as that entry
   with the password typed. An identifier no listed directory holds signs in against a
   local account, as on a client without directories.
3. A bind the directory accepts signs in as the account linked to the entry's
   `objectGUID` or `entryUUID`, wherever the entry has since moved in the tree.
4. On the entry's first sign-in no account is linked yet. One is created from the entry's
   `mail` when the client's `allowSignUp` and the organization's sign-up
   (`auth.registration.enabled`) allow it. The address is not counted as verified.
5. The rest of the sign-in is the same as for a local password: email verification where
   the organization requires it, the [second factor](https://www.nauthera.io/docs/v1/mfa), consent, then the
   application.

A directory account is never linked to an existing account by address, since a
directory's `mail` does not prove who controls the mailbox. An account that already holds
the address cannot sign in through the directory.

### When it is refused

The user sees the same answer as for a wrong password,
*The email or password you entered is incorrect.*, whatever the cause, so the page does
not reveal which accounts the directory holds. The `auth.sign_in.completed` audit record
says why:

| Cause | Meaning |
|---|---|
| `password_mismatch` | The directory refused the password. It counts toward the lockout like any wrong password, kept apart from a local account of the same name. |
| `directory_account_disabled` | The Active Directory account is disabled. |
| `directory_account_locked` | The account is locked out in the directory. |
| `directory_account_expired` | The account has expired. |
| `directory_password_expired` | The password has expired in the directory. |
| `directory_password_change_required` | The directory requires the user to change their password first. |
| `directory_account_restricted` | The sign-in is outside the account's logon hours, or from a workstation it may not use. |
| `directory_entry_ambiguous` | More than one entry matches the identifier. Tighten `userFilter` or pick a unique `usernameAttribute`. |
| `directory_entry_unlinkable` | The entry has neither `objectGUID` nor `entryUUID`, so no account can be linked to it. |
| `upstream_unavailable` | The directory did not answer, TLS failed, or it refused the service account's bind. The search stops here, since the identifier may be the unreachable directory's. |
| `sign_up_closed` | The entry has no account yet, and the organization takes no new accounts. |
| `identity_provider_not_allowed` | The client's `allowSignIn` or `allowSignUp` does not admit this sign-in. |
| `account_not_linked` | An account with the entry's address exists and is not linked to the entry. |
| `upstream_email_missing` | The entry has no `mail` to create an account from. |

An account state the directory reports is decided by the directory: a user enabled or
unlocked there can sign in on the next attempt, with no change here. Only Active Directory
reports these states. Other directories report only whether the password was accepted.

## Passwords stay in the directory

An account a directory signs in has no local password, and the server will not give it
one. The admin API refuses to set its password, send it a reset link, require a password
change, or send it an invitation. Each answers `409` with the problem
[`password_held_by_directory`](https://www.nauthera.io/docs/v1/runbooks/problems/password_held_by_directory), which names the
directory. Change, reset or expire the password with the directory's own tools.

`GET /v1/admin/orgs/{slug}/users/{id}/identities` lists the account's link, under the
provider `ldap/<slug>`.

## What the operator does

- **Registers and updates** the connection, sending the spec, the bind password and the CA
  bundle for every generation the server does not hold yet, and again whenever the Secret
  or ConfigMap changes.
- **Keeps the password in its Secret.** The resource never holds it. The server reports
  only a non-reversible fingerprint, in `status.bindPasswordFingerprint`, which is empty
  for a directory searched anonymously. Editing the Secret sends the new password.
- **Waits for its inputs.** A Secret, ConfigMap or key that does not exist leaves the
  resource in `Error` under `SecretNotFound`, `SecretKeyMissing`, `ConfigMapNotFound` or
  `ConfigMapKeyMissing`, naming it. A password that begins or ends with whitespace, such as
  the newline `echo` leaves, is `Error` under `SecretPadded`. Nothing is sent until it is
  fixed, and the resource is reconciled when the object changes.
- **Fixes the slug and organization.** `spec.slug` (the resource's name when unset) cannot
  be changed after creation, since accounts are linked under it. The organization follows
  from the resource's namespace (see
  [Which organization a resource configures](https://www.nauthera.io/docs/v1/operator#which-organization-a-resource-configures));
  `spec.organization` names it where several organizations admit the namespace, and once
  set cannot be changed.

Creating or replacing a connection is recorded as `ldap_directory.applied`, and deleting
it as `ldap_directory.deleted`.

### Deleting a resource

`spec.deletionPolicy` decides, and defaults to `Retain`:

- **`Retain`** leaves the connection registered, so deleting a namespace cannot take a
  sign-in method away from the organization's users.
- **`Delete`** removes the connection from the server.

A finalizer (`nauthera.io/ldapdirectory`) holds the resource until that is done. A removal
the server does not take keeps the resource in phase `Deleting` with reason
`DirectoryNotRemoved`, and is retried.

## Spec

{/* AUTO-GENERATED-SPEC-START */}

### `spec.bind`

**Type:** `object` | **Optional**

The service account the server searches the directory as. Left
out, it searches anonymously.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `dn` | `string` | Yes | `—` | The service account's distinguished name (RFC 4514). |
| `passwordSecretRef` | `object` | Yes | `—` | The Secret in this namespace holding the service account's password. Editing the Secret sends the new password to the server. |

### `spec.caBundleRef`

**Type:** `object` | **Optional**

The ConfigMap or Secret in this namespace holding the PEM
certificates the directory's certificate must chain to. Left out,
the system roots are trusted. Editing it sends the new bundle to
the server.

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `key` | `string` | No | `"ca.crt"` | The key holding the certificates. |
| `kind` | `string` | No | `"ConfigMap"` | ConfigMap or Secret. One of: `ConfigMap`, `Secret`. |
| `name` | `string` | Yes | `—` | The ConfigMap's or Secret's name. |

### `spec.deletionPolicy`

**Type:** `string` | **Optional**

What deleting this resource does to the connection it registered.
Retain leaves it registered, so deleting a namespace cannot take a
sign-in method away from the organization's users. Delete removes
it from the server. A removal that fails keeps the resource, in
phase Deleting, until it succeeds.

Defaults to `"Retain"`.

One of: `Retain`, `Delete`.

### `spec.organization`

**Type:** `string` | **Optional**

The slug of the organization this directory belongs to. Needed only
where more than one organization admits the namespace in its
spec.namespaces; otherwise the one that does is taken. The main
organization is "default". Immutable.

### `spec.readOnly`

**Type:** `boolean` | **Optional**

Whether the server only reads the directory, so a user's password
lives there alone: the server offers no password reset and no password
change to an account the directory signs in. True is the only value the
server accepts until password write-back exists.

Defaults to `true`.

### `spec.slug`

**Type:** `string` | **Optional**

The directory's name within its organization: a lowercase letter
followed by up to 62 lowercase letters, digits or dashes. Defaults
to the resource's name. Immutable.

### `spec.url`

**Type:** `string` | **Required**

The directory's address, host and port only, such as
ldaps://dc1.corp.example:636. The scheme is the TLS mode: ldaps://
is TLS from the first byte, and ldap:// is always upgraded with
StartTLS (RFC 4511 §4.14) before anything else is sent.

### `spec.userBaseDn`

**Type:** `string` | **Required**

The distinguished name (RFC 4514) under which a signing-in user is
searched for, such as ou=people,dc=corp,dc=example.

### `spec.userFilter`

**Type:** `string` | **Optional**

A search filter (RFC 4515) every user entry must also match, such
as (objectClass=person). Left out, every entry under userBaseDn
matches.

### `spec.usernameAttribute`

**Type:** `string` | **Required**

The attribute a sign-in identifier is matched against, such as uid
or sAMAccountName.

{/* AUTO-GENERATED-SPEC-END */}

## Status

The operator writes `status` from the server's answer. `phase` is one of `Pending`,
`Ready`, `Error` or `Deleting`. Beside the connection reasons every resource shares
(`Refused`, `Unreachable`, `Unsupported`, `CredentialUnavailable`,
`TrustAnchorUnavailable`) and the namespace reasons (`NamespaceNotAdmitted`,
`OrganizationAmbiguous`, `OrganizationNotReady`, `OrganizationChanged`), both explained in
the [OIDCClient reference](https://www.nauthera.io/docs/v1/oidc-client#status), the `Ready` condition's reason
is one of:

| Reason | Phase | Meaning |
|---|---|---|
| `Registered` | `Ready` | The server holds the current generation, password and CA bundle. |
| `NamespaceNotCovered` | `Error` | The resource's namespace is not in the operator's `--namespaces`. Nothing is sent. |
| `SecretNotFound` | `Error` | The Secret `spec.bind.passwordSecretRef` names does not exist in the resource's namespace. |
| `SecretKeyMissing` | `Error` | The Secret holds nothing under the key. |
| `SecretPadded` | `Error` | The password begins or ends with whitespace. |
| `ConfigMapNotFound` | `Error` | The ConfigMap `spec.caBundleRef` names does not exist in the resource's namespace. |
| `ConfigMapKeyMissing` | `Error` | The ConfigMap holds nothing under the key. |
| `DirectoryNotRemoved` | `Deleting` | Under `deletionPolicy: Delete`, the server has not removed the connection yet. |

`Ready` means the server holds the connection, not that the directory answers: see
[The connection](#the-connection) for how reachability is reported.

{/* AUTO-GENERATED-STATUS-START */}

| Field | Type | Description |
|---|---|---|
| `bindPasswordFingerprint` | `string` | A non-reversible fingerprint of the bind password the server holds, as the server reports it; never the password. Empty for a directory searched anonymously. |
| `conditions` | `[]object` | Standard Kubernetes conditions (Ready, ...). |
| `etag` | `string` | The server's revision of the connection as the operator last wrote it. |
| `issuer` | `string` | The issuer the server homed the connection at, after its default was applied. |
| `lastReconciledTime` | `string` | When the operator last reconciled the resource, whatever the outcome. A stale value means the operator is not running, not the leader, or cannot see the resource. |
| `observedGeneration` | `integer` | .metadata.generation last reconciled by the server. |
| `organization` | `string` | The slug of the organization the server holds the directory in. Once set, a namespace that comes to configure another organization is refused rather than moving the directory. |
| `phase` | `string` | High-level lifecycle phase. |

{/* AUTO-GENERATED-STATUS-END */}
