Files
OrchestrAD/README.md
Alphaeus Mote 5301b47f16 fix(crypto): explain that a decryption failure means the secret key changed
Stored credential secrets are encrypted with a key derived from
ORCHESTRAD_SECRET_KEY. When that value changes, the secrets are intact but
unreadable, and the only symptom was an opaque "decryption failed" surfacing
deep inside an unrelated operation:

  "preview failed: building LDAP client: failed to decrypt credential:
   decryption failed"

Nothing pointed at the real cause, so the error is now self-diagnosing:

- ErrDecryptionFailed states that the data was encrypted under a different
  ORCHESTRAD_SECRET_KEY (or is corrupted). GCM auth failure on a well-formed
  ciphertext is overwhelmingly a wrong-key case.
- The three credential decrypt sites name the credential, so the operator
  knows which password to restore or re-enter.
- New services.CheckSecretKey verifies every stored secret against the
  current key. It runs at startup (LogSecretKeyCheck) and in `doctor`, so a
  mismatched key is reported once, loudly, at the moment it is first used
  rather than during the next rule run. A correctly-sized but *different*
  key passed doctor's existing length check and still broke every bind.

Not fatal: the server still starts, since an operator may be mid-migration
or may intend to re-enter the secrets.

Verified on the demo instance: starting with a wrong key logs
"1 of 1 stored credential secret(s) CANNOT be decrypted ... [OrchestrAD]",
and the rule preview error now names both the credential and the key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:59:43 -04:00

396 lines
16 KiB
Markdown

# OrchestrAD
**OrchestrAD** is a modern, rule-based automation platform for Active Directory.
It enables dynamic group membership, object lifecycle orchestration, and policy-driven directory operations through a clean UI, REST API, and powerful scheduling engine.
---
## 🚀 Overview
OrchestrAD replaces static scripts and JSON-based tooling with a centralized, database-backed system for managing directory automation.
It allows administrators to define **rules** that evaluate Users, Computers, and Groups, then automatically perform actions such as:
* Adding/removing group memberships
* Moving objects to Organizational Units (OUs)
* Creating groups dynamically
* Nesting groups
* Enforcing directory structure and policy consistency
All configuration is managed through the UI or API — **no manual file editing required**.
---
## 📸 Screenshots
| Dashboard | Rules |
| --- | --- |
| [![Dashboard](docs/screenshots/dashboard.png)](docs/screenshots/dashboard.png) | [![Rules](docs/screenshots/rules.png)](docs/screenshots/rules.png) |
| Credentials | Sign in |
| --- | --- |
| [![Credentials](docs/screenshots/credentials.png)](docs/screenshots/credentials.png) | [![Sign in](docs/screenshots/login.png)](docs/screenshots/login.png) |
The entire UI is served by the single backend binary (embedded Next.js static export) — no separate web server to run.
---
## ✨ Key Features
### 🔁 Rule-Based Automation
* Visual rule builder with condition groups and logical operators
* Supports multiple object types (Users, Computers, Groups)
* Rich condition support (equals, regex, LDAP, comparisons, etc.)
* Multiple actions per rule with ordered execution
### 🧠 Dynamic Directory Orchestration
* Add/remove group membership with diff-based execution
* Move objects to OUs with dynamic path generation
* Auto-create groups and OUs when missing
* Support for nested group relationships
### ⏱️ Scheduling Engine
* Easy schedules (e.g., every 5 minutes)
* Advanced 6-field cron expressions
* Per-rule execution control and concurrency handling
### 🔐 Secure Authentication & Access
* Local authentication (Argon2-secured)
* OIDC support (via NextAuth/Auth.js)
* API key system (separate from user auth)
* Role-based access control (RBAC)
### 🔑 Credential Management
* Reusable, encrypted credential objects
* Secure storage using AEAD encryption
* Credential testing and validation
### 📊 Observability & Auditing
* Centralized logging with rotation and retention
* Human-readable logs with structured context
* Full audit trail for all actions and changes
* Rule execution history and summaries
### 💾 Backup & Restore
* Automatic database backups with retention
* Manual backup and restore support
* Safe restore with validation and rollback protection
### 📦 Cross-Platform Runtime
* Single Go binary
* Runs as:
* foreground process
* system service
* Docker container
* Supports Windows, macOS, and Linux (amd64 + arm64)
---
## 🧱 Architecture
* **Backend:** Go (Chi, sqlc, SQLite, robfig/cron)
* **Frontend:** Next.js + Material UI + Tailwind (Spike Template)
* **Database:** SQLite (WAL mode, migrations enabled)
* **Auth:** NextAuth/Auth.js (UI sessions) + backend RBAC/API keys
* **Directory Integration:** LDAP/LDAPS via go-ldap
---
## 🔐 Authentication Model
OrchestrAD separates authentication concerns:
* **Browser Sessions**
* Managed via NextAuth/Auth.js
* Supports OIDC and local login
* **API Access**
* Managed via API keys
* Keys can expire or persist indefinitely
* Shown only once at creation
* **Backend Authorization**
* All security decisions enforced server-side
* RBAC controls access to resources and actions
---
## 🔌 API & Automation
Everything the UI does is available over the REST API, so rules can be created and run programmatically.
* **Interactive docs (Swagger UI):** `https://<host>:18090/api/docs`
* **OpenAPI 3 spec:** `https://<host>:18090/api/openapi.json`
* **Compact route list:** `https://<host>:18090/api/routes`
The spec is **generated from the live router**, so it always reflects the endpoints the running build actually serves. Authenticate at `POST /api/v1/auth/login`, then send the returned token as `Authorization: Bearer <token>`.
### Access to the docs
The documentation endpoints are **not public** — they require the same authentication as the rest of the API:
* **From the dashboard:** sign in, then use the `</>` icon in the header or **Administration → API Docs** in the menu. Login sets a session cookie scoped to `/api/docs`, so the Swagger UI opens straight away in a new tab. Opening `/api/docs` while signed out redirects to the login page and returns you there afterwards.
* **From a client:** send `Authorization: Bearer <token>` or `X-API-Key: <key>` to `/api/openapi.json` or `/api/routes`.
The docs cookie is `HttpOnly` and path-scoped to `/api/docs`, so it is never sent to `/api/v1/*` and cannot be used to make API calls. Swagger's *Try it out* still needs a token or API key.
### Discovering routes
Both `/api/routes` and `/api/openapi.json` accept the same filters, so a client can ask for just the part of the API it cares about:
| Query | Meaning |
| --- | --- |
| `?method=post` | only POST operations |
| `?method=get,post` | GET **or** POST |
| `?path=rules` | paths containing `rules` |
| `?method=post&path=connections` | POST operations on connection routes |
`/api/routes` returns a flat list — method, path, summary, tag, whether the route is public, and `allowed`, which is `false` when a **read-scoped** API key cannot invoke it:
```powershell
$headers = New-Object 'System.Collections.Generic.Dictionary[String,String]'
$headers.Add('X-API-Key', $apiKey)
$routes = Invoke-RestMethod -Method 'Get' `
-Uri 'https://localhost:18090/api/routes?method=post&path=rules' `
-Headers $headers -SkipCertificateCheck
$routes.data.routes | Format-Table -Property 'method', 'path', 'allowed', 'summary'
```
The same filters work on the Swagger UI itself (`/api/docs?path=rules`), which loads a spec narrowed to those operations.
### Create a rule from PowerShell
A ready-to-run example lives at [`docs/examples/Create-OrchestrADRule.ps1`](docs/examples/Create-OrchestrADRule.ps1). It builds the headers and body as strongly-typed dictionaries, serializes the body with `ConvertTo-Json`, and uses full cmdlet names (no aliases):
```powershell
# Authenticate
$loginBody = New-Object 'System.Collections.Generic.Dictionary[String,Object]'
$loginBody.Add('username', 'admin')
$loginBody.Add('password', 'admin')
$login = Invoke-RestMethod -Method 'Post' -Uri 'https://localhost:18090/api/v1/auth/login' `
-ContentType 'application/json' -Body ($loginBody | ConvertTo-Json) -SkipCertificateCheck
$headers = New-Object 'System.Collections.Generic.Dictionary[String,String]'
$headers.Add('Authorization', "Bearer $($login.data.token)")
# Build the rule: users where department = Sales -> synced into a target group
$condition = New-Object 'System.Collections.Generic.Dictionary[String,Object]'
$condition.Add('attributeName', 'department'); $condition.Add('operator', 'Equals'); $condition.Add('comparisonValue', 'Sales')
$conditions = New-Object 'System.Collections.Generic.List[Object]'; $conditions.Add($condition)
$group = New-Object 'System.Collections.Generic.Dictionary[String,Object]'
$group.Add('joinOperator', 'AND'); $group.Add('conditions', $conditions)
$conditionGroups = New-Object 'System.Collections.Generic.List[Object]'; $conditionGroups.Add($group)
$actionConfig = New-Object 'System.Collections.Generic.Dictionary[String,Object]'
$actionConfig.Add('targetGroupDn', 'CN=Sales,OU=Groups,DC=corp,DC=com')
$actionConfig.Add('syncMode', 'FullSync'); $actionConfig.Add('createIfMissing', $true)
$action = New-Object 'System.Collections.Generic.Dictionary[String,Object]'
$action.Add('actionType', 'SyncGroupMembership'); $action.Add('configurationJson', ($actionConfig | ConvertTo-Json -Compress))
$actions = New-Object 'System.Collections.Generic.List[Object]'; $actions.Add($action)
$ruleBody = New-Object 'System.Collections.Generic.Dictionary[String,Object]'
$ruleBody.Add('name', 'Sales Team'); $ruleBody.Add('adConnectionId', '<connection-id>')
$ruleBody.Add('objectType', 'User'); $ruleBody.Add('executionMode', 'Apply'); $ruleBody.Add('groupJoinOperator', 'AND')
$ruleBody.Add('conditionGroups', $conditionGroups); $ruleBody.Add('actions', $actions)
Invoke-RestMethod -Method 'Post' -Uri 'https://localhost:18090/api/v1/rules' `
-Headers $headers -ContentType 'application/json' -Body ($ruleBody | ConvertTo-Json -Depth 10) -SkipCertificateCheck
```
Tip: `GET /api/v1/rules/metadata` returns the valid object types, operators, action types, and sync modes for building rule bodies.
---
## 🧾 Logging & Retention
Standard log format:
```
[TimestampUTC] - [Component] - [Level] - Message
```
**Log rotation** is automatic (max size, backups, and age; compressed):
| Setting | Env var | Default |
| --- | --- | --- |
| Max file size (MB) | `ORCHESTRAD_LOG_MAX_SIZE_MB` | `5` |
| Rotated copies kept | `ORCHESTRAD_LOG_MAX_BACKUPS` | `3` |
| Max age (days) | `ORCHESTRAD_LOG_MAX_AGE_DAYS` | `30` |
**Database maintenance** keeps history from growing forever — old rule runs and audit events are pruned and the database is compacted (`VACUUM`) on a schedule:
| Setting | Env var | Default |
| --- | --- | --- |
| Rule-run history retained (days) | `ORCHESTRAD_RUN_RETENTION_DAYS` | `90` |
| Audit history retained (days) | `ORCHESTRAD_AUDIT_RETENTION_DAYS` | `180` |
| Maintenance interval (hours) | `ORCHESTRAD_MAINTENANCE_INTERVAL_HOURS` | `24` |
| Run VACUUM | `ORCHESTRAD_MAINTENANCE_VACUUM` | `true` |
Error logs:
```
[TimestampUTC] - [Component] - [Level] - [File:Line:Column] - Message
```
Logs are designed to be:
* Clear and human-readable
* Operationally useful
* Free of unnecessary noise
---
## ⚙️ Configuration
Configuration is managed via:
* Environment variables
* Secure secrets
* Database-backed runtime configuration
Supports:
* Export and import of configuration (JSON)
* Schema versioning
* Credential portability with key validation
---
## 📥 Installation
### Windows (MSI)
Download `OrchestrAD-<version>-x64.msi` from the [latest release](https://prod.git.gracesolution.info/gsadmin/OrchestrAD/releases) and run it. The installer:
* Installs to `C:\Program Files\OrchestrAD`
* Registers and starts the **OrchestrAD** Windows service (auto-start)
* Records the install directory and version under `HKLM\Software\Grace Solutions\OrchestrAD`
* On upgrade, updates only the binaries — the database (`…\OrchestrAD\data`) is preserved
* On uninstall, stops and removes the service and deletes the program files (the data directory is left in place)
### Standalone binary (Windows / macOS / Linux)
Download the archive for your platform from the release (`…-<os>-<arch>.tar.gz` / `.zip`), extract the single `orchestrad` binary, and run it. See **Running & Service Management** below.
### Docker
```bash
docker pull prod.git.gracesolution.info/gsadmin/orchestrad:latest
docker run -d --name orchestrad -p 18090:18090 \
-e ORCHESTRAD_SECRET_KEY=<32+ byte secret> \
-v orchestrad-data:/data \
prod.git.gracesolution.info/gsadmin/orchestrad:latest
```
Or use the bundled [`docker-compose.yml`](docker-compose.yml). Images are tagged `latest` and the unified commit date `yyyy.MM.dd.HHmm`.
---
## ▶️ Running & Service Management
The single binary is both the app and its own service manager (Windows service, systemd/upstart/sysv, or launchd).
```
orchestrad <command>
run Run in the foreground (also the service entry point; used by Docker)
initialize Install and start the service (idempotent)
remove Stop and remove the service (idempotent)
install Alias for initialize
uninstall Alias for remove
start Start the installed service
stop Stop the installed service
init Initialize the database and apply migrations
migrate Apply database migrations
backup Create a manual backup
restore --file <path> Restore the database from a backup
doctor Validate configuration and system health
version Show version information
```
`initialize` and `remove` are idempotent, so they are safe to re-run (and are what the MSI uses under the hood). On first start the default admin account is `admin` / `admin`, and a password change is forced at first login.
Key environment variables (see the CLI help for the full list):
| Variable | Default | Purpose |
| --- | --- | --- |
| `ORCHESTRAD_DATA_PATH` | `./data` (next to the binary for the service) | Database, logs, backups |
| `ORCHESTRAD_SECRET_KEY` | insecure dev key | AEAD key for credential encryption (**set in production**) |
| `ORCHESTRAD_SECRET_KEY_FILE` | — | Read the secret key from a file instead (for Docker/K8s secrets) |
| `ORCHESTRAD_HOST` / `ORCHESTRAD_PORT` | `0.0.0.0` / `18090` | HTTP bind address |
| `ORCHESTRAD_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
> ⚠️ **The secret key must stay the same for the life of the installation.** Stored credential passwords are encrypted with it, so a changed or lost key leaves them intact but unreadable, and every directory bind fails. Back it up somewhere durable.
>
> On startup OrchestrAD verifies that the stored credentials decrypt with the current key and logs a clear error naming the affected credentials if they do not; `orchestrad doctor` runs the same check on demand. If the original key is genuinely gone, re-enter the affected passwords to re-encrypt them under the new key.
---
## 📦 Build & Versioning
* Version format: `yyyy.MM.dd.HHmm` (derived from the HEAD commit date in CI)
* **Pure Go** — SQLite via `modernc.org/sqlite`, so `CGO_ENABLED=0` and every target cross-compiles from one machine with no C toolchain
* Multi-platform: Windows, macOS, Linux (amd64 + arm64)
* Windows binaries embed the application icon and version metadata from `/resources/icons` (via `goversioninfo`)
Local build (Windows host, builds the frontend and stages it into the binary):
```powershell
./scripts/build.ps1 -All # or -Windows / -MacOS / -Linux
```
Output lands under `/binaries/<os>/<arch>/`.
---
## 🐳 Docker
OrchestrAD ships as a single small (~60 MB) image built by a multi-stage `Dockerfile` that compiles the Next.js UI, embeds it, and builds the static Go binary:
* Runs in foreground mode by default; graceful shutdown on `SIGTERM`
* `/data` holds the database, logs, and backups (bind-mount or named volume)
* `HEALTHCHECK` probes `/health` (answers both GET and HEAD)
---
## 🚀 CI/CD
A single Gitea Actions workflow ([`.gitea/workflows/release.yml`](.gitea/workflows/release.yml)) runs **only on merge to `main`** (docs-only merges are skipped — no per-commit or per-PR CI):
1. **`release`** job (`ubuntu-host`): runs the Go test suite as a gate, builds the frontend, cross-compiles all six binaries, builds and pushes the container image (`latest` + version), and creates the Gitea release with the binaries + checksums attached.
2. **`msi`** job (`windows-host`): wraps the Windows binary into the MSI with WiX and attaches it to the release.
### Required repository secret
| Secret | Purpose |
| --- | --- |
| `REGISTRY_PASSWORD` | A Gitea personal access token with `write:package` scope, used to push images to the container registry (with `REGISTRY_HOST` + `REGISTRY_USERNAME`). |
`REGISTRY_HOST` / `REGISTRY_USERNAME` point at the container registry (the Gitea instance itself by default). The release itself is created with the auto-injected Actions token, so no other secrets are needed.
---
## 📌 Use Cases
* Dynamic group membership automation
* Directory cleanup and normalization
* Organizational policy enforcement
* Zero-touch user and device placement
* Identity lifecycle orchestration
---