Active Directory Engagement Layer - A Go-based HTTPS server that provides REST API access to Active Directory using LDAP/LDAPS.
- Session-Based Authentication: Users login with their own AD credentials (no service account required)
- HTTPS Server: Secure TLS/SSL connections by default
- Active Directory Integration: Connect to AD/LDAP servers with configurable settings
- User Management: Get and edit user attributes
- Group Management: List groups, add/remove users from groups
- Group Inspection: Look up a single group and list its members, with nested groups linked rather than flattened
- Nested Group Resolution: Optionally expand a user's indirect memberships, inherited through groups that are themselves members of other groups
- LDAP/LDAPS Support: Connect via LDAP (389) or LDAPS (636) with optional CA certificates
- Session Management: Automatic session cleanup and secure session handling
- Middleware: CORS, logging, recovery, and security headers
adel/
├── main.go # Application entry point
├── config/
│ └── config.go # Configuration management
├── handlers/
│ └── handler.go # HTTP handlers for AD operations
├── middleware/
│ └── middleware.go # HTTP middleware (CORS, logging, auth)
├── models/
│ └── models.go # Models and DTOs
├── session/
│ └── manager.go # Session and LDAP connection management
├── certs/ # TLS certificates (generated)
├── .env.example # Environment variables template
├── Dockerfile # Docker configuration
├── Makefile # Build and development commands
└── go.mod # Go module definition
- Go 1.23 or higher
- Access to an Active Directory server
- OpenSSL (for generating certificates)
-
Clone the repository:
git clone <repository-url> cd adel
-
Copy the environment file:
cp .env.example .env
-
Update the
.envfile with your Active Directory settings:AD_SERVER=your-ad-server.example.com AD_PORT=389 AD_BASE_DN=dc=example,dc=com
-
Generate TLS certificates for HTTPS:
make certs
-
Install dependencies:
go mod tidy
# Build and run
make run
# Or run directly
make run-dev
# Or with Docker
make docker-build
make docker-runThe server will start on https://localhost:8080
curl -k https://localhost:8080/healthcurl -k -X POST https://localhost:8080/api/v1/login \
-H "Content-Type: application/json" \
-d '{"username":"johndoe","password":"password123"}'Response:
{
"success": true,
"sessionId": "abc123...",
"message": "Login successful",
"user": { ... }
}curl -k -X POST https://localhost:8080/api/v1/logout \
-H "Content-Type: application/json" \
-d '{"sessionId":"your-session-id"}'curl -k https://localhost:8080/api/v1/users/me \
-H "X-Session-ID: your-session-id"curl -k https://localhost:8080/api/v1/users/johndoe \
-H "X-Session-ID: your-session-id"curl -k -X PUT https://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-H "X-Session-ID: your-session-id" \
-d '{
"username": "johndoe",
"attributes": {
"title": "Senior Engineer",
"department": "Engineering"
}
}'A query (minimum 2 characters) or an explicit filter is required. Listing every
group is not supported: it walks the entire directory and is prohibitively expensive on
real domains. query matches cn and sAMAccountName as a substring, so partial input
returns partial matches. Results are capped by AD_MAX_SEARCH_RESULTS.
curl -k "https://localhost:8080/api/v1/groups?query=admins" \
-H "X-Session-ID: your-session-id"
# With optional baseDN
curl -k "https://localhost:8080/api/v1/groups?query=admins&baseDN=ou=Groups,dc=example,dc=com" \
-H "X-Session-ID: your-session-id"Omitting both query and filter returns 400.
Looks up a known set of groups in a single search. Used by the UI to show a user's own
memberships without listing the directory. Unlike /groups, this is not gated by
AD_SEARCH_ALLOWED_GROUPS, since the request is bounded by the DNs supplied.
curl -k -X POST https://localhost:8080/api/v1/groups/resolve \
-H "Content-Type: application/json" \
-H "X-Session-ID: your-session-id" \
-d '{"dns":["CN=Developers,OU=Groups,DC=example,DC=com"]}'A user's memberOf lists only direct memberships. Pass "nested": true to also walk
each resolved group's own memberOf, returning the groups the user belongs to
indirectly:
curl -k -X POST https://localhost:8080/api/v1/groups/resolve \
-H "Content-Type: application/json" \
-H "X-Session-ID: your-session-id" \
-d '{"dns":["CN=Developers,OU=Groups,DC=example,DC=com"],"nested":true}'Inherited groups are flagged with "nested": true; directly requested ones omit the
field:
{
"success": true,
"count": 2,
"groups": [
{
"dn": "CN=Developers,OU=Groups,DC=example,DC=com",
"cn": "Developers",
"sAMAccountName": "Developers"
},
{
"dn": "CN=All Staff,OU=Groups,DC=example,DC=com",
"cn": "All Staff",
"sAMAccountName": "All Staff",
"nested": true
}
]
}The transitive set is computed by the directory in a single extra search, using the same
LDAP_MATCHING_RULE_IN_CHAIN extension as the login-time allow-list check (see
Search Scope and Exclusions). There is no client-side
hierarchy walk, so nesting depth and membership cycles are the server's problem, not
ours. Results are capped by AD_MAX_SEARCH_RESULTS.
The expansion resolves memberships for the session's own user, so nested: true is
only meaningful when the supplied DNs are that user's groups. Directories that do not
implement the matching rule return nothing extra, and the response degrades to the
directly requested groups rather than failing.
In the web UI, the group membership table requests nested groups and labels them with a
nested badge. A Direct only / All toggle controls whether they are listed,
defaulting to direct only. Inherited memberships render with a disabled checkbox: they
live on the parent group, so removing the user from the nested group would have no
effect — the parent membership has to be changed instead.
curl -k -X POST https://localhost:8080/api/v1/groups/add-member \
-H "Content-Type: application/json" \
-H "X-Session-ID: your-session-id" \
-d '{"username":"johndoe","groupName":"Developers"}'curl -k -X POST https://localhost:8080/api/v1/groups/remove-member \
-H "Content-Type: application/json" \
-H "X-Session-ID: your-session-id" \
-d '{"username":"johndoe","groupName":"Developers"}'Returns a single group by cn or sAMAccountName, together with its members. Gated by
AD_SEARCH_ALLOWED_GROUPS, like /groups: it exposes directory contents beyond the
caller's own memberships, so it is directory browsing rather than a lookup bounded by who
the caller is.
curl -k "https://localhost:8080/api/v1/groups/Developers" \
-H "X-Session-ID: your-session-id"
# Group names containing spaces or slashes must be URL-encoded
curl -k "https://localhost:8080/api/v1/groups/Domain%20Admins" \
-H "X-Session-ID: your-session-id"{
"success": true,
"group": {
"dn": "CN=Developers,OU=Groups,DC=example,DC=com",
"cn": "Developers",
"sAMAccountName": "Developers",
"description": "Engineering team"
},
"members": [
{
"dn": "CN=John Doe,OU=Users,DC=example,DC=com",
"cn": "John Doe",
"sAMAccountName": "johndoe",
"displayName": "John Doe",
"mail": "john.doe@example.com"
},
{
"dn": "CN=Platform,OU=Groups,DC=example,DC=com",
"cn": "Platform",
"isGroup": true
}
],
"memberCount": 2
}Members are found by searching for entries whose memberOf points at the group, rather
than by reading the group's own member attribute. That keeps it to a single search, lets
the directory apply the size cap, and returns each member's attributes in the same round
trip.
Only direct members are listed. A member that is itself a group comes back as one
entry flagged "isGroup": true rather than being flattened into its own members; in the
UI that entry links through to that group's page.
Results are capped by AD_MAX_SEARCH_RESULTS. When the directory truncates the list the
response sets "truncated": true, and memberCount reflects what was returned rather
than the group's true size. Groups and containers hidden by AD_EXCLUDED_GROUPS /
AD_EXCLUDED_OBJECTS are filtered from the member list, and an excluded group reports
404 rather than 403, so its existence is not disclosed.
In the web UI this backs a Groups tab in the sidebar, where a group can be searched for and its members listed. Group names in the user's membership table are links to the same page. Both are hidden for sessions without search permission, so users are not offered a route that can only fail.
# GET request with query parameters
curl -k "https://localhost:8080/api/v1/search?baseDN=ou=Users,dc=example,dc=com&filter=(objectClass=user)&attributes=cn,mail,title&sizeLimit=100" \
-H "X-Session-ID: your-session-id"
# POST request with JSON body
curl -k -X POST https://localhost:8080/api/v1/search \
-H "Content-Type: application/json" \
-H "X-Session-ID: your-session-id" \
-d '{
"baseDN": "ou=Users,dc=example,dc=com",
"filter": "(objectClass=user)",
"attributes": ["cn", "mail", "title"],
"sizeLimit": 100
}'Response:
{
"success": true,
"entries": [
{
"dn": "CN=John Doe,OU=Users,DC=example,DC=com",
"attributes": {
"cn": ["John Doe"],
"mail": ["john.doe@example.com"],
"title": ["Engineer"]
}
}
],
"count": 1
}| Variable | Description | Default |
|---|---|---|
| PORT | Server port | 8080 |
| ENVIRONMENT | Environment (development/production) | development |
| READ_TIMEOUT | Read timeout in seconds | 60 |
| WRITE_TIMEOUT | Write timeout in seconds | 60 |
| IDLE_TIMEOUT | Idle timeout in seconds | 60 |
| AD_SERVER | Active Directory server hostname | (required) |
| AD_PORT | LDAP port | 389 |
| AD_BASE_DN | Base DN for searches | (required) |
| AD_USE_SSL | Use LDAPS instead of LDAP | false |
| AD_SKIP_TLS | Skip TLS verification | false |
| AD_CA_CERT_PATH | Path to CA certificate for LDAPS | |
| AD_USER_FILTER | LDAP filter for users | (objectClass=user) |
| AD_GROUP_FILTER | LDAP filter for groups | (objectClass=group) |
| AD_SEARCH_FILTER | LDAP filter for general searches | (objectClass=*) |
| AD_SEARCH_BASE_DN | Restrict searches to a specific OU (falls back to AD_BASE_DN if empty) | |
| AD_MAX_SEARCH_RESULTS | Maximum entries returned by any single LDAP search (LDAP size limit) | 200 |
| AD_EXCLUDED_OBJECTS | Semicolon-separated list of DN path fragments (OUs, CN containers) to exclude from results | |
| AD_EXCLUDED_GROUPS | Semicolon-separated list of group CNs or DNs to exclude from results | |
| AD_SEARCH_ALLOWED_GROUPS | Semicolon-separated group CNs or DNs allowed to use /api/v1/search; empty allows all authenticated users |
|
| TLS_ENABLED | Enable HTTPS | true |
| TLS_CERT_FILE | Path to TLS certificate | certs/server.crt |
| TLS_KEY_FILE | Path to TLS private key | certs/server.key |
To restrict API searches to a specific OU instead of the entire domain:
AD_BASE_DN=DC=example,DC=com # Used for authentication (broad)
AD_SEARCH_BASE_DN=OU=Corporate,DC=example,DC=com # Used for listing/searching (narrow)To hide specific containers from all search results (users, groups, and generic search):
AD_EXCLUDED_OBJECTS=CN=Builtin,DC=example,DC=com;OU=Disabled Users,DC=example,DC=comTo hide specific groups from group listings and lookups:
AD_EXCLUDED_GROUPS=Domain Admins;Schema Admins;Enterprise AdminsTo allow only members of selected AD groups to use the directory-browsing endpoints:
AD_SEARCH_ALLOWED_GROUPS=Helpdesk;CN=Directory Admins,OU=Groups,DC=example,DC=comThis gates /api/v1/search, /api/v1/groups (group search) and
/api/v1/groups/{groupName} (group inspection) — the endpoints that expose directory
contents beyond the caller's own record. /api/v1/groups/resolve is deliberately not
gated: it is bounded by the DNs the caller supplies.
Group CN and DN matching is case-insensitive. Use a full DN when groups with the same CN exist in multiple OUs.
Nested group membership is resolved: a user in a group that is itself a member of an
allowed group is permitted. This uses the Active Directory LDAP_MATCHING_RULE_IN_CHAIN
extension (OID 1.2.840.113556.1.4.1941); directories that do not implement it fall back
to direct memberOf values, which is logged at warning level.
Memberships are resolved once at login and cached for the lifetime of the session, so group changes in AD take effect on the user's next login rather than immediately.
Users outside the allow-list receive 403 from those endpoints, and the web UI hides the
search fields, the sidebar Groups tab and the group links in the membership table for
them, based on the canSearch field of GET /api/v1/session. The server-side check is the
enforcement point; hiding the controls is a convenience only.
To use LDAPS (LDAP over SSL):
AD_USE_SSL=true
AD_PORT=636
AD_CA_CERT_PATH=/path/to/ca-cert.pem # Optional: for certificate verificationmake help # Show all available commands
make build # Build the application
make run # Build and run
make run-dev # Run without building
make certs # Generate self-signed certificates
make test # Run tests
make fmt # Format code
make vet # Vet code
make lint # Run linter
make tidy # Tidy dependencies
make dev # Run with hot reload (requires air)make install-dev # Install air
make dev # Run with hot reload# Build image
make docker-build
# Run container
make docker-runA Helm chart is provided in charts/adel to deploy the container to Kubernetes. It is also published as a Helm repo on every release.
helm repo add adel https://dmakeienko.github.io/adel/
helm repo update
# Install (AD_SERVER and AD_BASE_DN are required)
helm install adel adel/adel \
--set config.ad.server=dc1.example.com \
--set config.ad.baseDN="dc=example,dc=com"
# Or with a values file
helm install adel adel/adel -f my-values.yaml --namespace adel --create-namespace
# Upgrade to the latest chart version
helm repo update
helm upgrade adel adel/adel --namespace adel# Lint and render
make helm-lint
make helm-template
# Install (AD_SERVER and AD_BASE_DN are required)
helm install adel charts/adel \
--set config.ad.server=dc1.example.com \
--set config.ad.baseDN="dc=example,dc=com"
# Or with a values file
helm install adel charts/adel -f my-values.yaml --namespace adel --create-namespaceKey values (see charts/adel/values.yaml for the full list):
| Value | Description | Default |
|---|---|---|
image.repository |
Container image | dmakeienko/adel |
image.tag |
Image tag | .Chart.AppVersion |
config.ad.server / config.ad.baseDN |
Required AD connection settings | "" |
tls.enabled / tls.secretName |
Terminate TLS in the Go server using a kubernetes.io/tls Secret |
false |
config.ad.caCertSecretName |
Secret containing a CA cert for LDAPS, mounted at /certs/ca |
"" |
ingress.enabled |
Expose via Ingress | false |
autoscaling.enabled |
Enable HPA | false |
In most setups, leave tls.enabled=false and terminate TLS at the Ingress instead.
- TLS Certificates: In production, use certificates from a trusted CA
- Session Tokens: Session IDs are cryptographically random 64-character hex strings
- No Service Account: Users authenticate with their own AD credentials
- Automatic Cleanup: Expired sessions are automatically removed
- Security Headers: HSTS, X-Frame-Options, X-XSS-Protection are enabled
This project is licensed under the MIT License - see the LICENSE file for details.