Dynamic groups, activity intelligence, API keys, Swagger, maintenance #11

Merged
gsadmin merged 34 commits from development into main 2026-09-02 23:03:12 +00:00
114 changed files with 9996 additions and 768 deletions
+4 -1
View File
@@ -385,9 +385,12 @@ jobs:
if (-not (Get-Command wix -ErrorAction SilentlyContinue)) {
dotnet tool install --global wix --version 5.0.2
}
# The wizard UI (install-dir + network dialogs, finish page) needs the
# WiX UI extension, pinned to match the WiX 5 tool.
wix extension add -g WixToolset.UI.wixext/5.0.2
New-Item -ItemType Directory -Force dist | Out-Null
$msi = "dist/OrchestrAD-$($env:VERSION)-x64.msi"
wix build -arch x64 installer/OrchestrAD.wxs `
wix build -arch x64 -ext WixToolset.UI.wixext installer/OrchestrAD.wxs `
-d Version="$($env:MSI_VERSION)" `
-d BinDir="$PWD/msistage" `
-d IconPath="$PWD/resources/icons/orchestrad.ico" `
+6 -2
View File
@@ -75,13 +75,17 @@ RUN mkdir -p /data/logs /data/backups && \
# Default environment
ENV ORCHESTRAD_DATA_PATH=/data
ENV ORCHESTRAD_LOG_LEVEL=info
# Containers typically sit behind an ingress/proxy that terminates TLS, so the
# image serves plain HTTP by default and the healthcheck below is HTTP. Set
# ORCHESTRAD_TLS_ENABLED=true to have the container manage its own certificate.
ENV ORCHESTRAD_TLS_ENABLED=false
# Expose port
EXPOSE 8080
EXPOSE 18090
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:18090/health || exit 1
# Switch to non-root user
USER orchestrad
+178 -22
View File
@@ -21,6 +21,20 @@ All configuration is managed through the UI or API — **no manual file editing
---
## 📸 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
@@ -113,14 +127,83 @@ OrchestrAD separates authentication concerns:
---
## 🧾 Logging Format
## 🔌 API & Automation
Standard logs:
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`
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>`.
### 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:
```
@@ -151,39 +234,112 @@ Supports:
---
## 📥 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_HOST` / `ORCHESTRAD_PORT` | `0.0.0.0` / `18090` | HTTP bind address |
| `ORCHESTRAD_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
---
## 📦 Build & Versioning
* Version format: `yyyy.MM.dd.HHmm`
* Multi-platform builds:
* 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`)
* Windows (amd64, arm64)
* macOS (amd64, arm64)
* Linux (amd64, arm64)
Local build (Windows host, builds the frontend and stages it into the binary):
Output structure:
```
/binaries
/windows
/macos
/linux
```powershell
./scripts/build.ps1 -All # or -Windows / -MacOS / -Linux
```
Windows builds embed application icon from `/resources/icons`.
Output lands under `/binaries/<os>/<arch>/`.
---
## 🐳 Docker
OrchestrAD supports containerized deployment:
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
* Supports bind mounts for:
* 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)
* database
* logs
* backups
* Health endpoints available for orchestration
---
## 🚀 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.
---
+5 -1
View File
@@ -3,6 +3,7 @@ module github.com/Grace-Solutions/OrchestrAD
go 1.25.0
require (
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.0.12
github.com/go-chi/cors v1.2.1
github.com/go-ldap/ldap/v3 v3.4.13
@@ -11,21 +12,24 @@ require (
github.com/kardianos/service v1.3.0
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
modernc.org/sqlite v1.58.0
software.sslmate.com/src/go-pkcs12 v0.7.3
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
+8
View File
@@ -2,6 +2,8 @@ github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -13,6 +15,8 @@ github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU=
@@ -68,6 +72,8 @@ golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
@@ -106,3 +112,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU=
software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+366
View File
@@ -0,0 +1,366 @@
//go:build adtest
// Package adtest holds opt-in integration tests that exercise the rule engine
// against a real Active Directory. They are excluded from normal builds by the
// `adtest` build tag and additionally skip unless ORCHESTRAD_AD_TEST_HOST is set.
//
// Run against a test AD (plain LDAP 389), e.g.:
//
// ORCHESTRAD_AD_TEST_HOST=172.16.32.65 \
// ORCHESTRAD_AD_TEST_BIND='OrchestrAD@gracesolutions.lab' \
// ORCHESTRAD_AD_TEST_PASSWORD='OrchestrAD' \
// ORCHESTRAD_AD_TEST_BASEDN='DC=gracesolutions,DC=lab' \
// go test -tags adtest ./internal/adtest/ -v
//
// Each test creates a uniquely named OU under the base DN, does its work there,
// and tree-deletes it on cleanup, so nothing is left behind.
package adtest
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
appldap "github.com/Grace-Solutions/OrchestrAD/internal/directory/ldap"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/rules/engine"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
ldapv3 "github.com/go-ldap/ldap/v3"
)
// treeDeleteOID is the AD control for recursive (subtree) delete.
const treeDeleteOID = "1.2.840.113556.1.4.805"
// securityGlobalGroupType is the groupType flag for a global security group.
const securityGlobalGroupType = "-2147483646"
type adEnv struct {
host, bind, password, baseDN string
port int
}
func loadEnv(t *testing.T) adEnv {
t.Helper()
host := os.Getenv("ORCHESTRAD_AD_TEST_HOST")
if host == "" {
t.Skip("ORCHESTRAD_AD_TEST_HOST not set; skipping AD integration tests")
}
port := 389
if p := os.Getenv("ORCHESTRAD_AD_TEST_PORT"); p != "" {
port, _ = strconv.Atoi(p)
}
return adEnv{
host: host,
port: port,
bind: os.Getenv("ORCHESTRAD_AD_TEST_BIND"),
password: os.Getenv("ORCHESTRAD_AD_TEST_PASSWORD"),
baseDN: os.Getenv("ORCHESTRAD_AD_TEST_BASEDN"),
}
}
// rawConn opens a plain go-ldap connection for fixture setup/teardown/verify.
func (e adEnv) rawConn(t *testing.T) *ldapv3.Conn {
t.Helper()
conn, err := ldapv3.DialURL(fmt.Sprintf("ldap://%s:%d", e.host, e.port))
if err != nil {
t.Fatalf("dial: %v", err)
}
if err := conn.Bind(e.bind, e.password); err != nil {
conn.Close()
t.Fatalf("bind: %v", err)
}
t.Cleanup(func() { conn.Close() })
return conn
}
// appClient returns the application's LDAP client (used by the engine).
func (e adEnv) appClient(t *testing.T) *appldap.Client {
t.Helper()
c := appldap.NewClient(&appldap.Config{
Hosts: []string{e.host},
Port: e.port,
UseTLS: false,
BindDN: e.bind,
BindPassword: e.password,
RootDN: e.baseDN,
Timeout: 15 * time.Second,
})
if err := c.Connect(); err != nil {
t.Fatalf("app client connect: %v", err)
}
if err := c.Bind(); err != nil {
t.Fatalf("app client bind: %v", err)
}
t.Cleanup(c.Close)
return c
}
// makeOU creates a unique test OU and registers a recursive-delete cleanup.
func (e adEnv) makeOU(t *testing.T, conn *ldapv3.Conn, label string) string {
t.Helper()
name := fmt.Sprintf("OrchestrAD-%s-%d", label, time.Now().UnixNano())
dn := fmt.Sprintf("OU=%s,%s", name, e.baseDN)
add := ldapv3.NewAddRequest(dn, nil)
add.Attribute("objectClass", []string{"organizationalUnit"})
if err := conn.Add(add); err != nil {
t.Fatalf("create OU %s: %v", dn, err)
}
t.Cleanup(func() {
del := ldapv3.NewDelRequest(dn, []ldapv3.Control{ldapv3.NewControlString(treeDeleteOID, true, "")})
if err := conn.Del(del); err != nil {
t.Logf("cleanup tree-delete %s: %v", dn, err)
}
})
return dn
}
func addUser(t *testing.T, conn *ldapv3.Conn, dn string, attrs map[string][]string) {
t.Helper()
add := ldapv3.NewAddRequest(dn, nil)
add.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"})
// Disabled account (no password set; plain LDAP cannot set unicodePwd).
add.Attribute("userAccountControl", []string{"514"})
for k, v := range attrs {
add.Attribute(k, v)
}
if err := conn.Add(add); err != nil {
t.Fatalf("create user %s: %v", dn, err)
}
}
func addGroup(t *testing.T, conn *ldapv3.Conn, dn, sam string) {
t.Helper()
add := ldapv3.NewAddRequest(dn, nil)
add.Attribute("objectClass", []string{"group"})
add.Attribute("sAMAccountName", []string{sam})
add.Attribute("groupType", []string{securityGlobalGroupType})
if err := conn.Add(add); err != nil {
t.Fatalf("create group %s: %v", dn, err)
}
}
func groupMembers(t *testing.T, conn *ldapv3.Conn, groupDN string) []string {
t.Helper()
res, err := conn.Search(ldapv3.NewSearchRequest(
groupDN, ldapv3.ScopeBaseObject, ldapv3.NeverDerefAliases, 0, 0, false,
"(objectClass=*)", []string{"member"}, nil,
))
if err != nil {
t.Fatalf("read group members %s: %v", groupDN, err)
}
if len(res.Entries) == 0 {
return nil
}
return res.Entries[0].GetAttributeValues("member")
}
func exists(t *testing.T, conn *ldapv3.Conn, dn string) bool {
t.Helper()
res, err := conn.Search(ldapv3.NewSearchRequest(
dn, ldapv3.ScopeBaseObject, ldapv3.NeverDerefAliases, 0, 0, false,
"(objectClass=*)", []string{"distinguishedName"}, nil,
))
if err != nil {
return false
}
return len(res.Entries) == 1
}
func containsDN(list []string, dn string) bool {
for _, e := range list {
if strings.EqualFold(e, dn) {
return true
}
}
return false
}
func conn(baseDN string) *models.ADConnection {
return &models.ADConnection{RootDN: baseDN, DefaultSearchScope: "Subtree"}
}
func actionJSON(t *testing.T, cfg models.ActionConfig) string {
t.Helper()
b, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal action config: %v", err)
}
return string(b)
}
// ruleUsersWhere builds a User rule with one equals-condition and the given actions.
func ruleUsersWhere(baseDN, attr, value string, actions ...models.RuleAction) *models.Rule {
v := value
return &models.Rule{
ID: "test-rule",
Name: "Integration Test Rule",
ObjectType: string(types.ObjectTypeUser),
BaseDNOverride: &baseDN,
GroupJoinOperator: string(types.JoinOperatorAND),
ExecutionMode: string(types.ExecutionModeApply),
ConditionGroups: []models.RuleConditionGroup{{
IsEnabled: true,
JoinOperator: string(types.JoinOperatorAND),
Conditions: []models.RuleCondition{{
IsEnabled: true,
AttributeName: attr,
Operator: string(types.OperatorEquals),
ComparisonValue: &v,
}},
}},
Actions: actions,
}
}
func newEngine() *engine.Engine {
return engine.NewEngine(logging.Default())
}
// TestAddUsersToGroupByCondition: users with department=Engineering are added to
// a target group; a Sales user is not.
func TestAddUsersToGroupByCondition(t *testing.T) {
e := loadEnv(t)
raw := e.rawConn(t)
ou := e.makeOU(t, raw, "addgroup")
alice := "CN=oad-alice," + ou
bob := "CN=oad-bob," + ou
carol := "CN=oad-carol," + ou
addUser(t, raw, alice, map[string][]string{"sAMAccountName": {"oad-alice"}, "department": {"Engineering"}})
addUser(t, raw, bob, map[string][]string{"sAMAccountName": {"oad-bob"}, "department": {"Sales"}})
addUser(t, raw, carol, map[string][]string{"sAMAccountName": {"oad-carol"}, "department": {"Engineering"}})
group := "CN=oad-engineers," + ou
addGroup(t, raw, group, "oad-engineers")
rule := ruleUsersWhere(ou, "department", "Engineering", models.RuleAction{
ID: "a1", ActionType: string(types.ActionAddToGroup), IsEnabled: true,
ConfigurationJSON: actionJSON(t, models.ActionConfig{TargetGroupDN: group}),
})
res := newEngine().Execute(context.Background(), rule, conn(e.baseDN), e.appClient(t))
if res.Status != types.RunStatusCompleted {
t.Fatalf("run status = %s, want Completed; errors: %+v", res.Status, res.Errors)
}
if res.ObjectsMatched != 2 {
t.Errorf("ObjectsMatched = %d, want 2 (alice, carol)", res.ObjectsMatched)
}
members := groupMembers(t, raw, group)
if !containsDN(members, alice) || !containsDN(members, carol) {
t.Errorf("group missing expected members; got %v", members)
}
if containsDN(members, bob) {
t.Errorf("Sales user bob should not be a member; got %v", members)
}
}
// TestAddToGroupCreatesMissingGroup: AddToGroup with createIfMissing provisions
// the group before adding the member.
func TestAddToGroupCreatesMissingGroup(t *testing.T) {
e := loadEnv(t)
raw := e.rawConn(t)
ou := e.makeOU(t, raw, "creategroup")
dave := "CN=oad-dave," + ou
addUser(t, raw, dave, map[string][]string{"sAMAccountName": {"oad-dave"}, "department": {"IT"}})
group := "CN=oad-it-created," + ou // does not exist yet
if exists(t, raw, group) {
t.Fatalf("precondition: group should not exist")
}
rule := ruleUsersWhere(ou, "department", "IT", models.RuleAction{
ID: "a1", ActionType: string(types.ActionAddToGroup), IsEnabled: true,
ConfigurationJSON: actionJSON(t, models.ActionConfig{
TargetGroupDN: group, CreateIfMissing: true, GroupType: "Security", GroupScope: "Global",
}),
})
res := newEngine().Execute(context.Background(), rule, conn(e.baseDN), e.appClient(t))
if res.Status != types.RunStatusCompleted {
t.Fatalf("run status = %s, want Completed; %+v", res.Status, res.Errors)
}
if !exists(t, raw, group) {
t.Fatalf("group was not created")
}
if members := groupMembers(t, raw, group); !containsDN(members, dave) {
t.Errorf("created group missing member dave; got %v", members)
}
}
// TestMoveToOU: a matching user is moved into a target OU (created on demand).
func TestMoveToOU(t *testing.T) {
e := loadEnv(t)
raw := e.rawConn(t)
ou := e.makeOU(t, raw, "move")
erin := "CN=oad-erin," + ou
addUser(t, raw, erin, map[string][]string{"sAMAccountName": {"oad-erin"}, "department": {"Relocate"}})
targetOU := "OU=oad-moved," + ou
rule := ruleUsersWhere(ou, "department", "Relocate", models.RuleAction{
ID: "a1", ActionType: string(types.ActionMoveToOu), IsEnabled: true,
ConfigurationJSON: actionJSON(t, models.ActionConfig{TargetOU: targetOU, CreateOUIfMissing: true}),
})
res := newEngine().Execute(context.Background(), rule, conn(e.baseDN), e.appClient(t))
if res.Status != types.RunStatusCompleted {
t.Fatalf("run status = %s, want Completed; %+v", res.Status, res.Errors)
}
movedDN := "CN=oad-erin," + targetOU
if !exists(t, raw, movedDN) {
t.Errorf("user was not moved to %s", movedDN)
}
if exists(t, raw, erin) {
t.Errorf("user still present at old DN %s", erin)
}
}
// TestMoveToNestedOUByCanonical: a canonical target path (domain/OU/OU/OU) is
// converted to a DN and every missing OU is created idempotently, then the
// object is moved into the leaf OU. Re-running the rule is a no-op.
func TestMoveToNestedOUByCanonical(t *testing.T) {
e := loadEnv(t)
raw := e.rawConn(t)
ou := e.makeOU(t, raw, "nested")
frank := "CN=oad-frank," + ou
addUser(t, raw, frank, map[string][]string{"sAMAccountName": {"oad-frank"}, "department": {"Nest"}})
// Canonical target two levels below the (existing) test OU.
ouCanonical := appldap.CanonicalName(ou, "")
targetCanonical := ouCanonical + "/Level1/Level2"
rule := ruleUsersWhere(ou, "department", "Nest", models.RuleAction{
ID: "a1", ActionType: string(types.ActionMoveToOu), IsEnabled: true,
ConfigurationJSON: actionJSON(t, models.ActionConfig{TargetOU: targetCanonical, CreateOUIfMissing: true}),
})
res := newEngine().Execute(context.Background(), rule, conn(e.baseDN), e.appClient(t))
if res.Status != types.RunStatusCompleted {
t.Fatalf("run status = %s, want Completed; %+v", res.Status, res.Errors)
}
level1 := "OU=Level1," + ou
level2 := "OU=Level2," + level1
if !exists(t, raw, level1) || !exists(t, raw, level2) {
t.Fatalf("nested OU path not created (level1=%v level2=%v)", exists(t, raw, level1), exists(t, raw, level2))
}
movedDN := "CN=oad-frank," + level2
if !exists(t, raw, movedDN) {
t.Errorf("user not moved to %s", movedDN)
}
// Idempotent: running again does not error (OUs exist, user already there).
res2 := newEngine().Execute(context.Background(), rule, conn(e.baseDN), e.appClient(t))
if res2.Status != types.RunStatusCompleted {
t.Errorf("re-run status = %s, want Completed; %+v", res2.Status, res2.Errors)
}
}
+53
View File
@@ -0,0 +1,53 @@
// Package api - activity intelligence handlers
package api
import (
"net/http"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/services"
)
// ActivityHandler serves aggregated action intelligence and the drill-in feed.
type ActivityHandler struct {
service *services.ActivityService
logger *logging.Logger
}
// NewActivityHandler creates a new ActivityHandler.
func NewActivityHandler(service *services.ActivityService, logger *logging.Logger) *ActivityHandler {
return &ActivityHandler{service: service, logger: logger}
}
// Summary handles GET /api/v1/activity/summary
func (h *ActivityHandler) Summary(w http.ResponseWriter, r *http.Request) {
summary, err := h.service.Summary()
if err != nil {
h.logger.Error("ActivityHandler", "Summary failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to compute activity summary")
return
}
WriteJSON(w, http.StatusOK, summary)
}
// List handles GET /api/v1/activity — the filtered, paginated action feed.
func (h *ActivityHandler) List(w http.ResponseWriter, r *http.Request) {
p := ParsePagination(r)
q := r.URL.Query()
filter := services.ActivityFilter{
Category: q.Get("category"),
ActionType: q.Get("actionType"),
Status: q.Get("status"),
RuleID: q.Get("ruleId"),
Search: q.Get("search"),
Offset: (p.Page - 1) * p.PageSize,
Limit: p.PageSize,
}
records, total, err := h.service.ListActions(filter)
if err != nil {
h.logger.Error("ActivityHandler", "List failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to list activity")
return
}
WriteList(w, records, p.Page, p.PageSize, total)
}
+4
View File
@@ -27,6 +27,7 @@ func NewAPIKeysHandler(service *services.APIKeyService, auditService *audit.Serv
type APIKeyRequest struct {
Name string `json:"name"`
UserID string `json:"userId,omitempty"`
Scope string `json:"scope,omitempty"` // "read" or "readwrite" (default)
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
}
@@ -36,6 +37,7 @@ type APIKeyResponse struct {
UserID string `json:"userId"`
Name string `json:"name"`
KeyPrefix string `json:"keyPrefix"`
Scope string `json:"scope"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
IsEnabled bool `json:"isEnabled"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
@@ -102,6 +104,7 @@ func (h *APIKeysHandler) Create(w http.ResponseWriter, r *http.Request) {
result, err := h.service.Create(services.CreateAPIKeyInput{
UserID: userID,
Name: req.Name,
Scope: req.Scope,
ExpiresAt: req.ExpiresAt,
})
if err != nil {
@@ -180,6 +183,7 @@ func apiKeyToResponse(k *services.APIKey) APIKeyResponse {
UserID: k.UserID,
Name: k.Name,
KeyPrefix: k.KeyPrefix,
Scope: k.Scope,
ExpiresAt: k.ExpiresAt,
IsEnabled: k.IsEnabled,
LastUsedAt: k.LastUsedAt,
+26 -5
View File
@@ -2,6 +2,7 @@
package api
import (
"net"
"net/http"
"github.com/Grace-Solutions/OrchestrAD/internal/audit"
@@ -50,12 +51,32 @@ func emitAudit(svc *audit.Service, r *http.Request, eventType audit.EventType, r
_ = svc.Log(event)
}
// clientIP returns the caller's address. The proxy middleware already rewrites
// r.RemoteAddr from X-Forwarded-For / X-Real-IP when the immediate peer is a
// trusted proxy, so we trust r.RemoteAddr rather than re-reading the (spoofable)
// forwarded headers here.
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return fwd
}
if real := r.Header.Get("X-Real-IP"); real != "" {
return real
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// requestBaseURL returns the externally-visible scheme://host for the request.
// It reflects X-Forwarded-Proto/Host only when the proxy middleware applied
// them (trusted peer); otherwise it falls back to the actual scheme and Host.
func requestBaseURL(r *http.Request) string {
scheme := r.URL.Scheme
if scheme == "" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := r.Host
if host == "" {
host = r.URL.Host
}
return scheme + "://" + host
}
+22 -3
View File
@@ -2,6 +2,8 @@
package api
import (
"encoding/json"
"io"
"net/http"
"github.com/Grace-Solutions/OrchestrAD/internal/audit"
@@ -46,14 +48,31 @@ func (h *ConfigHandler) Export(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, payload)
}
// Import handles POST /api/v1/config/import
// Import handles POST /api/v1/config/import. It accepts either the wrapped
// shape {"payload": <config>, "dryRun": bool} or a bare exported config object
// (as produced by GET /config/export), so a downloaded export file can be
// re-imported directly; with the bare shape, dryRun comes from ?dryRun=true.
func (h *ConfigHandler) Import(w http.ResponseWriter, r *http.Request) {
var req ImportRequest
if err := DecodeJSON(r, &req); err != nil {
raw, err := io.ReadAll(io.LimitReader(r.Body, 32<<20))
if err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
var req ImportRequest
if err := json.Unmarshal(raw, &req); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
// Fall back to a bare exported config when no wrapped payload was supplied.
if req.Payload.FormatVersion == "" {
var bare services.ConfigExport
if err := json.Unmarshal(raw, &bare); err == nil && bare.FormatVersion != "" {
req.Payload = bare
req.DryRun = r.URL.Query().Get("dryRun") == "true"
}
}
report, err := h.service.Import(&req.Payload, services.ImportOptions{DryRun: req.DryRun})
if err != nil {
h.logger.Error("ConfigHandler", "Import failed: %v", err)
@@ -259,6 +259,115 @@ func (h *ConnectionsHandler) QueryPreview(w http.ResponseWriter, r *http.Request
WriteJSON(w, http.StatusOK, result)
}
// DirectorySearch handles GET /api/v1/ad-connections/{id}/directory?type=Group&q=&limit=
// It returns groups or OUs (and users/computers) matching an optional
// substring, for the rule editor's target pickers.
func (h *ConnectionsHandler) DirectorySearch(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
conn, err := h.repo.GetByID(id)
if err != nil {
h.logger.Error("ConnectionsHandler", "GetByID failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to load connection")
return
}
if conn == nil {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "Connection not found")
return
}
q := r.URL.Query()
objectType := q.Get("type")
if objectType == "" {
objectType = "Group"
}
limit := 50
if n, err := parseInt(q.Get("limit")); err == nil && n > 0 {
limit = n
}
objects, err := h.service.SearchDirectory(conn, objectType, q.Get("q"), limit)
if err != nil {
h.logger.Error("ConnectionsHandler", "DirectorySearch failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Directory search failed")
return
}
WriteJSON(w, http.StatusOK, objects)
}
// Attributes handles GET /api/v1/ad-connections/{id}/attributes?q=&limit=
// It returns directory schema attributes matching the substring q, for the
// filter builder's attribute autocomplete.
func (h *ConnectionsHandler) Attributes(w http.ResponseWriter, r *http.Request) {
conn, ok := h.loadConn(w, r)
if !ok {
return
}
q := r.URL.Query()
limit := 50
if n, err := parseInt(q.Get("limit")); err == nil && n > 0 {
limit = n
}
objectType := q.Get("objectType")
if objectType == "" {
objectType = "User"
}
attrs, err := h.service.SchemaAttributes(conn, objectType, q.Get("q"), limit)
if err != nil {
h.logger.Error("ConnectionsHandler", "Attributes failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to read schema attributes")
return
}
WriteJSON(w, http.StatusOK, attrs)
}
// AttributeValues handles
// GET /api/v1/ad-connections/{id}/attribute-values?attribute=&objectType=&q=&baseDn=&limit=
// It returns the distinct values present for one attribute, for the value
// autocomplete in the filter builder.
func (h *ConnectionsHandler) AttributeValues(w http.ResponseWriter, r *http.Request) {
conn, ok := h.loadConn(w, r)
if !ok {
return
}
q := r.URL.Query()
attribute := q.Get("attribute")
if attribute == "" {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "attribute is required")
return
}
objectType := q.Get("objectType")
if objectType == "" {
objectType = "User"
}
limit := 50
if n, err := parseInt(q.Get("limit")); err == nil && n > 0 {
limit = n
}
values, err := h.service.DistinctAttributeValues(conn, objectType, attribute, q.Get("q"), q.Get("baseDn"), limit)
if err != nil {
h.logger.Error("ConnectionsHandler", "AttributeValues failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to read attribute values")
return
}
WriteJSON(w, http.StatusOK, values)
}
// loadConn resolves the {id} connection or writes the appropriate error.
func (h *ConnectionsHandler) loadConn(w http.ResponseWriter, r *http.Request) (*models.ADConnection, bool) {
id := chi.URLParam(r, "id")
conn, err := h.repo.GetByID(id)
if err != nil {
h.logger.Error("ConnectionsHandler", "GetByID failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to load connection")
return nil, false
}
if conn == nil {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "Connection not found")
return nil, false
}
return conn, true
}
func requestToConnection(req *ConnectionRequest, existing *models.ADConnection) *models.ADConnection {
conn := &models.ADConnection{}
if existing != nil {
+37 -9
View File
@@ -24,11 +24,16 @@ func AuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler {
return
}
user, err := authService.ValidateSession(token)
user, scope, err := validateAuth(authService, r, token)
if err != nil {
WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Invalid or expired token")
return
}
// A read-scoped API key may only perform safe (read) requests.
if scope == "read" && !isReadMethod(r.Method) {
WriteError(w, http.StatusForbidden, ErrCodeForbidden, "This API key is read-only")
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
@@ -42,7 +47,7 @@ func OptionalAuthMiddleware(authService *auth.Service) func(http.Handler) http.H
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token != "" {
user, err := authService.ValidateSession(token)
user, _, err := validateAuth(authService, r, token)
if err == nil {
ctx := context.WithValue(r.Context(), userContextKey, user)
r = r.WithContext(ctx)
@@ -92,24 +97,47 @@ func GetUserFromContext(ctx context.Context) *models.User {
return user
}
// validateAuth resolves the caller to a user and, for API-key auth, the key's
// scope ("read"/"readwrite"; empty for a session, which is full access). A token
// from the X-API-Key header is validated as an API key; otherwise it is
// validated as a session token, falling back to API-key validation so a key
// sent as a bearer token also works.
func validateAuth(authService *auth.Service, r *http.Request, token string) (*models.User, string, error) {
if r.Header.Get("X-API-Key") != "" {
return authService.ValidateAPIKey(token)
}
user, err := authService.ValidateSession(token)
if err != nil {
if apiUser, scope, apiErr := authService.ValidateAPIKey(token); apiErr == nil {
return apiUser, scope, nil
}
return nil, "", err
}
return user, "", nil
}
func isReadMethod(method string) bool {
return method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions
}
func extractToken(r *http.Request) string {
// Check Authorization header
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return auth[7:]
}
// Check X-API-Key header for API key auth
if apiKey := r.Header.Get("X-API-Key"); apiKey != "" {
return apiKey
}
// Check cookie (for browser sessions)
cookie, err := r.Cookie("session")
if err == nil && cookie.Value != "" {
return cookie.Value
}
return ""
}
@@ -132,26 +160,26 @@ func CSRFMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
// Skip CSRF for API key auth
if r.Header.Get("X-API-Key") != "" {
next.ServeHTTP(w, r)
return
}
// Skip CSRF for Bearer token auth (typically from NextAuth)
if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
next.ServeHTTP(w, r)
return
}
// TODO: Validate CSRF token from header/body
csrfToken := r.Header.Get("X-CSRF-Token")
if csrfToken == "" {
WriteError(w, http.StatusForbidden, ErrCodeForbidden, "CSRF token required")
return
}
// TODO: Validate the token against stored session
next.ServeHTTP(w, r)
})
+354
View File
@@ -0,0 +1,354 @@
// Package api - OIDC / SSO authentication and configuration handlers.
//
// OIDC configuration is resolved with app_settings (set via the UI) taking
// precedence over environment variables, then defaults (see
// services.SettingsService.Resolve*). This lets an administrator configure SSO
// entirely from the UI while env vars can still seed a working setup.
package api
import (
"context"
"crypto/rand"
"encoding/base64"
"net/http"
"net/url"
"strings"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/audit"
"github.com/Grace-Solutions/OrchestrAD/internal/auth"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/services"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// OIDCHandler handles SSO login/callback and admin configuration.
type OIDCHandler struct {
authService *auth.Service
settings *services.SettingsService
auditService *audit.Service
logger *logging.Logger
}
// NewOIDCHandler creates an OIDCHandler.
func NewOIDCHandler(authService *auth.Service, settings *services.SettingsService, auditService *audit.Service, logger *logging.Logger) *OIDCHandler {
return &OIDCHandler{authService: authService, settings: settings, auditService: auditService, logger: logger}
}
type oidcConfig struct {
Enabled bool
Issuer string
ClientID string
ClientSecret string
RedirectURL string
Scopes string
UsernameClaim string
EmailClaim string
NameClaim string
DefaultRole string
}
func (h *OIDCHandler) resolve() oidcConfig {
r := h.settings
return oidcConfig{
Enabled: r.ResolveBool("oidc.enabled", "ORCHESTRAD_OIDC_ENABLED", false),
Issuer: r.ResolveString("oidc.issuer", "ORCHESTRAD_OIDC_ISSUER", ""),
ClientID: r.ResolveString("oidc.client_id", "ORCHESTRAD_OIDC_CLIENT_ID", ""),
ClientSecret: r.ResolveString("oidc.client_secret", "ORCHESTRAD_OIDC_CLIENT_SECRET", ""),
RedirectURL: r.ResolveString("oidc.redirect_url", "ORCHESTRAD_OIDC_REDIRECT_URL", ""),
Scopes: r.ResolveString("oidc.scopes", "ORCHESTRAD_OIDC_SCOPES", "openid profile email"),
UsernameClaim: r.ResolveString("oidc.username_claim", "ORCHESTRAD_OIDC_USERNAME_CLAIM", "preferred_username"),
EmailClaim: r.ResolveString("oidc.email_claim", "ORCHESTRAD_OIDC_EMAIL_CLAIM", "email"),
NameClaim: r.ResolveString("oidc.name_claim", "ORCHESTRAD_OIDC_NAME_CLAIM", "name"),
DefaultRole: r.ResolveString("oidc.default_role", "ORCHESTRAD_OIDC_DEFAULT_ROLE", ""),
}
}
// redirectURI returns the callback URL: the configured one if set, else derived
// from the request so it works without explicit configuration. It must be
// identical between the login and callback requests.
func (h *OIDCHandler) redirectURI(r *http.Request, cfg oidcConfig) string {
if cfg.RedirectURL != "" {
return cfg.RedirectURL
}
// Derive from the request. requestBaseURL honors X-Forwarded-Proto/Host only
// when the proxy middleware already applied them (trusted peer), so an
// untrusted client cannot forge the callback host.
return requestBaseURL(r) + "/api/v1/auth/oidc/callback"
}
func (h *OIDCHandler) build(ctx context.Context, cfg oidcConfig, redirectURL string) (*oidc.Provider, oauth2.Config, error) {
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, oauth2.Config{}, err
}
oc := oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: redirectURL,
Scopes: strings.Fields(cfg.Scopes),
}
return provider, oc, nil
}
// Status handles GET /api/v1/auth/oidc/status (public) so the login page can
// decide whether to show the SSO button.
func (h *OIDCHandler) Status(w http.ResponseWriter, r *http.Request) {
cfg := h.resolve()
enabled := cfg.Enabled && cfg.Issuer != "" && cfg.ClientID != ""
WriteJSON(w, http.StatusOK, map[string]any{
"enabled": enabled,
"loginUrl": "/api/v1/auth/oidc/login",
})
}
// Login handles GET /api/v1/auth/oidc/login (public): starts the auth-code +
// PKCE flow and redirects to the provider.
func (h *OIDCHandler) Login(w http.ResponseWriter, r *http.Request) {
cfg := h.resolve()
if !cfg.Enabled || cfg.Issuer == "" || cfg.ClientID == "" {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "SSO is not enabled")
return
}
redirectURL := h.redirectURI(r, cfg)
_, oc, err := h.build(r.Context(), cfg, redirectURL)
if err != nil {
h.logger.Error("OIDC", "provider discovery failed: %v", err)
WriteError(w, http.StatusBadGateway, ErrCodeInternalError, "SSO provider is unreachable")
return
}
state := randToken()
nonce := randToken()
verifier := oauth2.GenerateVerifier()
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
h.setFlowCookie(w, "oidc_state", state, secure)
h.setFlowCookie(w, "oidc_nonce", nonce, secure)
h.setFlowCookie(w, "oidc_verifier", verifier, secure)
authURL := oc.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(verifier))
http.Redirect(w, r, authURL, http.StatusFound)
}
// Callback handles GET /api/v1/auth/oidc/callback (public).
func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
cfg := h.resolve()
if !cfg.Enabled {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "SSO is not enabled")
return
}
if e := r.URL.Query().Get("error"); e != "" {
h.failRedirect(w, r, e)
return
}
state, _ := r.Cookie("oidc_state")
nonce, _ := r.Cookie("oidc_nonce")
verifier, _ := r.Cookie("oidc_verifier")
if state == nil || nonce == nil || verifier == nil || r.URL.Query().Get("state") != state.Value {
h.failRedirect(w, r, "invalid_state")
return
}
h.clearFlowCookies(w, r.TLS != nil)
redirectURL := h.redirectURI(r, cfg)
provider, oc, err := h.build(r.Context(), cfg, redirectURL)
if err != nil {
h.failRedirect(w, r, "provider_error")
return
}
oauth2Token, err := oc.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(verifier.Value))
if err != nil {
h.logger.Warn("OIDC", "token exchange failed: %v", err)
h.failRedirect(w, r, "exchange_failed")
return
}
rawID, ok := oauth2Token.Extra("id_token").(string)
if !ok {
h.failRedirect(w, r, "no_id_token")
return
}
idToken, err := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}).Verify(r.Context(), rawID)
if err != nil {
h.logger.Warn("OIDC", "id_token verification failed: %v", err)
h.failRedirect(w, r, "invalid_id_token")
return
}
if idToken.Nonce != nonce.Value {
h.failRedirect(w, r, "invalid_nonce")
return
}
var claims map[string]any
_ = idToken.Claims(&claims)
identity := auth.OIDCIdentity{
ProviderID: cfg.Issuer,
Subject: idToken.Subject,
Username: claimString(claims, cfg.UsernameClaim, idToken.Subject),
Email: claimString(claims, cfg.EmailClaim, ""),
DisplayName: claimString(claims, cfg.NameClaim, ""),
DefaultRole: cfg.DefaultRole,
}
result, err := h.authService.LoginOIDC(identity)
if err != nil {
h.logger.Warn("OIDC", "provisioning failed for subject %s: %v", idToken.Subject, err)
emitAudit(h.auditService, r, audit.EventLogin, "User", "", "OIDCLogin", false, map[string]any{"subject": idToken.Subject}, err.Error())
h.failRedirect(w, r, "provisioning_failed")
return
}
emitAudit(h.auditService, r, audit.EventLogin, "User", result.User.ID, "OIDCLogin", true, map[string]any{"username": result.User.Username}, "")
// Hand the session token to the SPA via the URL fragment (not the query, so
// it is not logged by intermediaries), then let the app store it.
frag := url.Values{}
frag.Set("oidc_token", result.SessionToken)
frag.Set("expires_at", result.ExpiresAt.Format(time.RFC3339))
http.Redirect(w, r, "/login#"+frag.Encode(), http.StatusFound)
}
func (h *OIDCHandler) failRedirect(w http.ResponseWriter, r *http.Request, reason string) {
http.Redirect(w, r, "/login#oidc_error="+url.QueryEscape(reason), http.StatusFound)
}
// --- Admin configuration ---
type oidcConfigResponse struct {
Enabled bool `json:"enabled"`
Issuer string `json:"issuer"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"` // redacted
RedirectURL string `json:"redirectUrl"`
Scopes string `json:"scopes"`
UsernameClaim string `json:"usernameClaim"`
EmailClaim string `json:"emailClaim"`
NameClaim string `json:"nameClaim"`
DefaultRole string `json:"defaultRole"`
}
// GetConfig handles GET /api/v1/auth/oidc/config (admin).
func (h *OIDCHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
c := h.resolve()
secret := ""
if c.ClientSecret != "" {
secret = "********"
}
WriteJSON(w, http.StatusOK, oidcConfigResponse{
Enabled: c.Enabled, Issuer: c.Issuer, ClientID: c.ClientID, ClientSecret: secret,
RedirectURL: c.RedirectURL, Scopes: c.Scopes, UsernameClaim: c.UsernameClaim,
EmailClaim: c.EmailClaim, NameClaim: c.NameClaim, DefaultRole: c.DefaultRole,
})
}
type oidcConfigRequest struct {
Enabled *bool `json:"enabled"`
Issuer *string `json:"issuer"`
ClientID *string `json:"clientId"`
ClientSecret *string `json:"clientSecret"`
RedirectURL *string `json:"redirectUrl"`
Scopes *string `json:"scopes"`
UsernameClaim *string `json:"usernameClaim"`
EmailClaim *string `json:"emailClaim"`
NameClaim *string `json:"nameClaim"`
DefaultRole *string `json:"defaultRole"`
}
// PutConfig handles PUT /api/v1/auth/oidc/config (admin). Only provided fields
// are changed. When enabling, the issuer is validated via OIDC discovery.
func (h *OIDCHandler) PutConfig(w http.ResponseWriter, r *http.Request) {
var req oidcConfigRequest
if err := DecodeJSON(r, &req); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
set := func(key, val string, sensitive bool) error {
return h.settings.Upsert(services.Setting{Key: key, Value: val, ValueType: "string", IsSensitive: sensitive})
}
if req.Issuer != nil {
_ = set("oidc.issuer", strings.TrimSpace(*req.Issuer), false)
}
if req.ClientID != nil {
_ = set("oidc.client_id", strings.TrimSpace(*req.ClientID), false)
}
if req.ClientSecret != nil && *req.ClientSecret != "" && *req.ClientSecret != "********" {
_ = set("oidc.client_secret", *req.ClientSecret, true)
}
if req.RedirectURL != nil {
_ = set("oidc.redirect_url", strings.TrimSpace(*req.RedirectURL), false)
}
if req.Scopes != nil {
_ = set("oidc.scopes", strings.TrimSpace(*req.Scopes), false)
}
if req.UsernameClaim != nil {
_ = set("oidc.username_claim", strings.TrimSpace(*req.UsernameClaim), false)
}
if req.EmailClaim != nil {
_ = set("oidc.email_claim", strings.TrimSpace(*req.EmailClaim), false)
}
if req.NameClaim != nil {
_ = set("oidc.name_claim", strings.TrimSpace(*req.NameClaim), false)
}
if req.DefaultRole != nil {
_ = set("oidc.default_role", strings.TrimSpace(*req.DefaultRole), false)
}
// Validate discovery before allowing enable.
cfg := h.resolve()
if req.Enabled != nil && *req.Enabled {
if cfg.Issuer == "" || cfg.ClientID == "" {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "issuer and clientId are required to enable SSO")
return
}
if _, err := oidc.NewProvider(r.Context(), cfg.Issuer); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "OIDC discovery failed for the issuer: "+err.Error())
return
}
}
if req.Enabled != nil {
_ = h.settings.Upsert(services.Setting{Key: "oidc.enabled", Value: boolStr(*req.Enabled), ValueType: "bool"})
}
emitAudit(h.auditService, r, audit.EventConfigChange, "OIDC", "config", "Update", true, map[string]any{"enabled": req.Enabled != nil && *req.Enabled}, "")
h.GetConfig(w, r)
}
// --- helpers ---
func (h *OIDCHandler) setFlowCookie(w http.ResponseWriter, name, value string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: value, Path: "/api/v1/auth/oidc",
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode,
MaxAge: 600,
})
}
func (h *OIDCHandler) clearFlowCookies(w http.ResponseWriter, secure bool) {
for _, n := range []string{"oidc_state", "oidc_nonce", "oidc_verifier"} {
http.SetCookie(w, &http.Cookie{Name: n, Value: "", Path: "/api/v1/auth/oidc", HttpOnly: true, Secure: secure, MaxAge: -1})
}
}
func randToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func claimString(claims map[string]any, key, fallback string) string {
if key != "" {
if v, ok := claims[key]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
}
return fallback
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
+342
View File
@@ -0,0 +1,342 @@
// Package api - OpenAPI (Swagger) spec generated from the live router.
//
// The path set is produced by walking the actual chi router, so new routes
// appear in the spec automatically and it cannot drift out of sync. A small
// registry supplies rich summaries, request bodies, and response schemas for
// the automation-critical operations (auth, rules, connection introspection);
// every other route is documented generically from its method and path.
package api
import (
"encoding/json"
"net/http"
"regexp"
"sort"
"strings"
"sync"
"github.com/go-chi/chi/v5"
)
// OpenAPIHandler serves the generated spec and a Swagger UI. The spec is built
// lazily on first request (once the whole router exists) and cached.
type OpenAPIHandler struct {
router chi.Router
once sync.Once
spec []byte
}
// NewOpenAPIHandler creates a handler that documents the given router.
func NewOpenAPIHandler(router chi.Router) *OpenAPIHandler {
return &OpenAPIHandler{router: router}
}
// Spec handles GET /api/openapi.json.
func (h *OpenAPIHandler) Spec(w http.ResponseWriter, r *http.Request) {
h.once.Do(func() {
spec := BuildOpenAPISpec(h.router)
h.spec, _ = json.Marshal(spec)
})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(h.spec)
}
// UI handles GET /api/docs — a self-contained Swagger UI page (CDN assets).
func (h *OpenAPIHandler) UI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(swaggerUIHTML))
}
var pathParamRe = regexp.MustCompile(`\{([^}]+)\}`)
// BuildOpenAPISpec walks the router and assembles an OpenAPI 3.0 document.
func BuildOpenAPISpec(router chi.Router) map[string]any {
paths := map[string]map[string]any{}
_ = chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
// Only document the JSON API surface and the health/version probes.
if !strings.HasPrefix(route, "/api/") && route != "/health" {
return nil
}
route = strings.TrimSuffix(route, "/*")
if route == "" {
return nil
}
if paths[route] == nil {
paths[route] = map[string]any{}
}
paths[route][strings.ToLower(method)] = operationFor(method, route)
return nil
})
// map[string]map[string]any -> map[string]any for JSON.
pathsOut := make(map[string]any, len(paths))
for p, ops := range paths {
pathsOut[p] = ops
}
return map[string]any{
"openapi": "3.0.3",
"info": map[string]any{
"title": "OrchestrAD API",
"version": "1",
"description": "Active Directory rule automation. Authenticate at /api/v1/auth/login, then send the returned token as `Authorization: Bearer <token>`.",
},
"servers": []any{map[string]any{"url": "/", "description": "This server"}},
"security": []any{
map[string]any{"bearerAuth": []any{}},
},
"paths": pathsOut,
"components": map[string]any{
"securitySchemes": map[string]any{
"bearerAuth": map[string]any{"type": "http", "scheme": "bearer"},
},
"schemas": openAPISchemas(),
},
}
}
// publicRoutes need no auth; their operations advertise empty security.
var publicRoutes = map[string]bool{
"/health": true,
"/api/health": true,
"/api/v1/health": true,
"/api/v1/version": true,
"/api/v1/auth/login": true,
"/api/v1/auth/csrf": true,
"/api/v1/auth/oidc/status": true,
"/api/v1/auth/oidc/login": true,
"/api/v1/auth/oidc/callback": true,
"/api/openapi.json": true,
"/api/docs": true,
}
func operationFor(method, route string) map[string]any {
op := map[string]any{
"tags": []any{tagFor(route)},
"responses": defaultResponses(),
}
// Path parameters.
var params []any
for _, m := range pathParamRe.FindAllStringSubmatch(route, -1) {
params = append(params, map[string]any{
"name": m[1], "in": "path", "required": true,
"schema": map[string]any{"type": "string"},
})
}
if len(params) > 0 {
op["parameters"] = params
}
if publicRoutes[route] {
op["security"] = []any{}
}
if meta, ok := operationRegistry[method+" "+route]; ok {
for k, v := range meta {
op[k] = v
}
} else {
op["summary"] = strings.ToUpper(method) + " " + route
}
return op
}
func tagFor(route string) string {
parts := strings.Split(strings.TrimPrefix(route, "/api/v1/"), "/")
if len(parts) == 0 || parts[0] == "" {
return "system"
}
return parts[0]
}
func defaultResponses() map[string]any {
return map[string]any{
"200": map[string]any{"description": "OK"},
"400": map[string]any{"description": "Bad request"},
"401": map[string]any{"description": "Unauthorized"},
}
}
func jsonBody(schemaRef string) map[string]any {
return map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/" + schemaRef},
},
},
}
}
// operationRegistry supplies rich detail for the operations most useful to
// automate. Everything else is documented generically.
var operationRegistry = map[string]map[string]any{
"POST /api/v1/auth/login": {
"summary": "Log in and obtain a bearer token",
"security": []any{},
"requestBody": jsonBody("LoginRequest"),
"responses": map[string]any{
"200": map[string]any{"description": "Token issued"},
"401": map[string]any{"description": "Invalid credentials"},
},
},
"GET /api/v1/rules/metadata": {
"summary": "Vocabulary for building rules (object types, operators, action types, sync modes, common attributes)",
},
"POST /api/v1/rules": {
"summary": "Create a rule (dynamic group)",
"requestBody": jsonBody("RuleInput"),
},
"PUT /api/v1/rules/{id}": {
"summary": "Update a rule; conditionGroups/actions replace the rule's logic when present",
"requestBody": jsonBody("RuleInput"),
},
"POST /api/v1/rules/preview": {
"summary": "Preview an unsaved rule draft (matched objects + planned +add/-remove)",
"requestBody": jsonBody("RuleInput"),
},
"POST /api/v1/rules/{id}/run": {
"summary": "Run a rule now",
},
"GET /api/v1/ad-connections/{id}/directory": {
"summary": "Search groups / OUs on the connection (for target pickers)",
"parameters": []any{
map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
map[string]any{"name": "type", "in": "query", "schema": map[string]any{"type": "string", "enum": []any{"Group", "OU", "User", "Computer"}}},
map[string]any{"name": "q", "in": "query", "schema": map[string]any{"type": "string"}},
},
},
"GET /api/v1/ad-connections/{id}/attributes": {
"summary": "Schema attributes applicable to an object type (filter builder)",
"parameters": []any{
map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
map[string]any{"name": "objectType", "in": "query", "schema": map[string]any{"type": "string", "enum": []any{"User", "Computer", "Group"}}},
map[string]any{"name": "q", "in": "query", "schema": map[string]any{"type": "string"}},
},
},
"GET /api/v1/ad-connections/{id}/attribute-values": {
"summary": "Distinct values present for an attribute (value autocomplete)",
"parameters": []any{
map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
map[string]any{"name": "attribute", "in": "query", "required": true, "schema": map[string]any{"type": "string"}},
map[string]any{"name": "objectType", "in": "query", "schema": map[string]any{"type": "string"}},
map[string]any{"name": "q", "in": "query", "schema": map[string]any{"type": "string"}},
},
},
"GET /api/v1/activity": {
"summary": "Action feed (filter by ruleId, category, status, actionType, search)",
},
}
func openAPISchemas() map[string]any {
str := map[string]any{"type": "string"}
boolean := map[string]any{"type": "boolean"}
return map[string]any{
"LoginRequest": map[string]any{
"type": "object",
"required": []any{"username", "password"},
"properties": map[string]any{
"username": str,
"password": str,
},
},
"RuleConditionInput": map[string]any{
"type": "object",
"required": []any{"attributeName", "operator"},
"properties": map[string]any{
"attributeName": map[string]any{"type": "string", "example": "department"},
"operator": map[string]any{"type": "string", "example": "Equals", "description": "See /rules/metadata operators"},
"comparisonValue": map[string]any{"type": "string", "example": "Sales"},
"customLdapExpression": map[string]any{"type": "string", "description": "Used when operator is CustomLdap"},
"negate": boolean,
"isEnabled": boolean,
},
},
"RuleConditionGroupInput": map[string]any{
"type": "object",
"properties": map[string]any{
"name": str,
"joinOperator": map[string]any{"type": "string", "enum": []any{"AND", "OR"}},
"negate": boolean,
"isEnabled": boolean,
"conditions": map[string]any{
"type": "array",
"items": map[string]any{"$ref": "#/components/schemas/RuleConditionInput"},
},
},
},
"RuleActionInput": map[string]any{
"type": "object",
"required": []any{"actionType", "configurationJson"},
"properties": map[string]any{
"actionType": map[string]any{"type": "string", "example": "SyncGroupMembership", "description": "SyncGroupMembership | MoveToOu | EnsureGroupExists | AddToGroup"},
"configurationJson": map[string]any{"type": "string", "example": "{\"targetGroupDn\":\"CN=Sales,OU=Groups,DC=corp,DC=com\",\"syncMode\":\"FullSync\",\"createIfMissing\":true}"},
"isEnabled": boolean,
},
},
"RuleInput": map[string]any{
"type": "object",
"required": []any{"name", "adConnectionId", "objectType"},
"properties": map[string]any{
"name": str,
"description": str,
"isEnabled": boolean,
"adConnectionId": str,
"objectType": map[string]any{"type": "string", "enum": []any{"User", "Computer", "Group"}},
"baseDnOverride": str,
"searchScopeOverride": map[string]any{"type": "string", "enum": []any{"Base", "OneLevel", "Subtree"}},
"scheduleId": str,
"executionMode": map[string]any{"type": "string", "enum": []any{"Apply", "PreviewOnly"}},
"groupJoinOperator": map[string]any{"type": "string", "enum": []any{"AND", "OR"}},
"stopOnError": boolean,
"conditionGroups": map[string]any{
"type": "array",
"items": map[string]any{"$ref": "#/components/schemas/RuleConditionGroupInput"},
},
"actions": map[string]any{
"type": "array",
"items": map[string]any{"$ref": "#/components/schemas/RuleActionInput"},
},
},
},
}
}
// DocumentedAPIPaths returns the set of "METHOD /path" the spec covers, for the
// drift test that keeps documentation and routing in lock-step.
func DocumentedAPIPaths(router chi.Router) []string {
spec := BuildOpenAPISpec(router)
var out []string
for p, ops := range spec["paths"].(map[string]any) {
for m := range ops.(map[string]any) {
out = append(out, strings.ToUpper(m)+" "+p)
}
}
sort.Strings(out)
return out
}
const swaggerUIHTML = `<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>OrchestrAD API</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui.min.css"/>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui-bundle.min.js"></script>
<script>
window.onload = function () {
window.ui = SwaggerUIBundle({
url: "/api/openapi.json",
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis],
layout: "BaseLayout",
});
};
</script>
</body>
</html>`
+62
View File
@@ -0,0 +1,62 @@
package api
import (
"net/http"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
func TestBuildOpenAPISpecFromRouter(t *testing.T) {
r := chi.NewRouter()
noop := func(w http.ResponseWriter, _ *http.Request) {}
r.Route("/api/v1", func(r chi.Router) {
r.Get("/rules/metadata", noop)
r.Post("/rules", noop)
r.Get("/ad-connections/{id}/directory", noop)
})
spec := BuildOpenAPISpec(r)
paths, ok := spec["paths"].(map[string]any)
if !ok {
t.Fatal("paths missing")
}
// Walked routes are documented.
for _, p := range []string{"/api/v1/rules/metadata", "/api/v1/rules", "/api/v1/ad-connections/{id}/directory"} {
if _, ok := paths[p]; !ok {
t.Errorf("path %q not documented", p)
}
}
// Registry detail is applied: POST /rules carries a request body.
post := paths["/api/v1/rules"].(map[string]any)["post"].(map[string]any)
if _, ok := post["requestBody"]; !ok {
t.Errorf("POST /rules should have a requestBody from the registry")
}
// Path parameters are derived.
dir := paths["/api/v1/ad-connections/{id}/directory"].(map[string]any)["get"].(map[string]any)
if _, ok := dir["parameters"]; !ok {
t.Errorf("directory route should declare path/query parameters")
}
// Components include the RuleInput schema used for automation.
schemas := spec["components"].(map[string]any)["schemas"].(map[string]any)
if _, ok := schemas["RuleInput"]; !ok {
t.Errorf("RuleInput schema missing")
}
}
// TestOperationRegistryWellFormed guards against typos: every registry key must
// be "<METHOD> /api/..." so it can only enrich a real API route.
func TestOperationRegistryWellFormed(t *testing.T) {
methods := map[string]bool{"GET": true, "POST": true, "PUT": true, "DELETE": true, "PATCH": true}
for key := range operationRegistry {
parts := strings.SplitN(key, " ", 2)
if len(parts) != 2 || !methods[parts[0]] || !strings.HasPrefix(parts[1], "/api/") {
t.Errorf("malformed operationRegistry key %q", key)
}
}
}
+172 -7
View File
@@ -12,6 +12,7 @@ import (
"github.com/Grace-Solutions/OrchestrAD/internal/rules/engine"
"github.com/Grace-Solutions/OrchestrAD/internal/rules/runner"
"github.com/Grace-Solutions/OrchestrAD/internal/services"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
"github.com/go-chi/chi/v5"
)
@@ -38,9 +39,10 @@ type PreviewResponse struct {
}
type previewMatchedObject struct {
DN string `json:"dn"`
ObjectType string `json:"objectType"`
Attributes map[string][]string `json:"attributes,omitempty"`
DN string `json:"dn"`
CanonicalName string `json:"canonicalName,omitempty"`
ObjectType string `json:"objectType"`
Attributes map[string][]string `json:"attributes,omitempty"`
}
type previewPlannedAction struct {
@@ -80,6 +82,56 @@ func (h *RulesHandler) Preview(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, toPreviewResponse(result))
}
// PreviewSpec handles POST /api/v1/rules/preview — a preview of an unsaved
// rule draft (the editor's live "matching objects" panel).
func (h *RulesHandler) PreviewSpec(w http.ResponseWriter, r *http.Request) {
var req RuleRequest
if err := DecodeJSON(r, &req); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
if req.ADConnectionID == "" || req.ObjectType == "" {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "adConnectionId and objectType are required")
return
}
rule := &models.Rule{
ID: "preview",
Name: req.Name,
ADConnectionID: req.ADConnectionID,
ObjectType: req.ObjectType,
BaseDNOverride: req.BaseDNOverride,
SearchScopeOverride: req.SearchScopeOverride,
GroupJoinOperator: req.GroupJoinOperator,
ExecutionMode: string(types.ExecutionModePreviewOnly),
}
if req.ConditionGroups != nil {
rule.ConditionGroups = toModelGroups(*req.ConditionGroups)
}
if req.Actions != nil {
rule.Actions = toModelActions(*req.Actions)
}
result, err := h.runner.PreviewRuleSpec(r.Context(), rule)
if err != nil {
h.logger.Error("RulesHandler", "PreviewSpec failed: %v", err)
if isNotFound(err) {
WriteError(w, http.StatusNotFound, ErrCodeNotFound, err.Error())
return
}
WriteErrorWithDetails(w, http.StatusInternalServerError, ErrCodeInternalError, "preview failed", err.Error())
return
}
WriteJSON(w, http.StatusOK, toPreviewResponse(result))
}
// Metadata handles GET /api/v1/rules/metadata — the operator-facing vocabulary
// the editor needs (object types, operators, action types, sync modes, and
// common attributes) so its dropdowns stay in lock-step with the backend.
func (h *RulesHandler) Metadata(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, ruleMetadata())
}
// Run handles POST /api/v1/rules/{id}/run
func (h *RulesHandler) Run(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
@@ -120,9 +172,10 @@ func toPreviewResponse(p *engine.PreviewResult) PreviewResponse {
}
for _, m := range p.MatchedObjects {
resp.MatchedObjects = append(resp.MatchedObjects, previewMatchedObject{
DN: m.DN,
ObjectType: m.ObjectType,
Attributes: m.Attributes,
DN: m.DN,
CanonicalName: m.CanonicalName,
ObjectType: m.ObjectType,
Attributes: m.Attributes,
})
}
for _, a := range p.PlannedActions {
@@ -151,7 +204,10 @@ func resolveTriggeredBy(r *http.Request) string {
return "api"
}
// RuleRequest represents a create/update rule payload.
// RuleRequest represents a create/update rule payload. When conditionGroups or
// actions are present (non-nil), the rule's logic is replaced wholesale;
// omitting them leaves existing logic untouched (so legacy scalar-only callers
// keep working).
type RuleRequest struct {
Name string `json:"name"`
Description *string `json:"description,omitempty"`
@@ -165,6 +221,101 @@ type RuleRequest struct {
GroupJoinOperator string `json:"groupJoinOperator"`
MaxParallelism *int `json:"maxParallelism,omitempty"`
StopOnError *bool `json:"stopOnError,omitempty"`
ConditionGroups *[]ruleGroupRequest `json:"conditionGroups,omitempty"`
Actions *[]ruleActionRequest `json:"actions,omitempty"`
}
type ruleGroupRequest struct {
Name *string `json:"name,omitempty"`
IsEnabled *bool `json:"isEnabled,omitempty"`
JoinOperator string `json:"joinOperator"`
Negate bool `json:"negate"`
Conditions []ruleConditionRequest `json:"conditions"`
}
type ruleConditionRequest struct {
IsEnabled *bool `json:"isEnabled,omitempty"`
AttributeName string `json:"attributeName"`
Operator string `json:"operator"`
ValueType string `json:"valueType,omitempty"`
ComparisonValue *string `json:"comparisonValue,omitempty"`
CustomLdapExpression *string `json:"customLdapExpression,omitempty"`
Negate bool `json:"negate"`
CaseSensitive bool `json:"caseSensitive"`
}
type ruleActionRequest struct {
ActionType string `json:"actionType"`
IsEnabled *bool `json:"isEnabled,omitempty"`
ConfigurationJSON string `json:"configurationJson"`
RollbackMode *string `json:"rollbackMode,omitempty"`
}
func boolOrTrue(p *bool) bool {
if p == nil {
return true
}
return *p
}
func toModelGroups(reqs []ruleGroupRequest) []models.RuleConditionGroup {
groups := make([]models.RuleConditionGroup, 0, len(reqs))
for _, gr := range reqs {
g := models.RuleConditionGroup{
Name: gr.Name,
IsEnabled: boolOrTrue(gr.IsEnabled),
JoinOperator: gr.JoinOperator,
Negate: gr.Negate,
}
for _, cr := range gr.Conditions {
g.Conditions = append(g.Conditions, models.RuleCondition{
IsEnabled: boolOrTrue(cr.IsEnabled),
AttributeName: cr.AttributeName,
Operator: cr.Operator,
ValueType: cr.ValueType,
ComparisonValue: cr.ComparisonValue,
CustomLdapExpression: cr.CustomLdapExpression,
Negate: cr.Negate,
CaseSensitive: cr.CaseSensitive,
})
}
groups = append(groups, g)
}
return groups
}
func toModelActions(reqs []ruleActionRequest) []models.RuleAction {
actions := make([]models.RuleAction, 0, len(reqs))
for _, ar := range reqs {
actions = append(actions, models.RuleAction{
ActionType: ar.ActionType,
IsEnabled: boolOrTrue(ar.IsEnabled),
ConfigurationJSON: ar.ConfigurationJSON,
RollbackMode: ar.RollbackMode,
})
}
return actions
}
// applyLogic persists a rule's condition groups / actions when the request
// carried them, then returns the freshly re-loaded rule.
func (h *RulesHandler) applyLogic(req *RuleRequest, ruleID string) (*models.Rule, error) {
if req.ConditionGroups == nil && req.Actions == nil {
return h.service.GetByID(ruleID)
}
var groups []models.RuleConditionGroup
var actions []models.RuleAction
if req.ConditionGroups != nil {
groups = toModelGroups(*req.ConditionGroups)
}
if req.Actions != nil {
actions = toModelActions(*req.Actions)
}
if err := h.service.ReplaceLogic(ruleID, groups, actions); err != nil {
return nil, err
}
return h.service.GetByID(ruleID)
}
// RuleSummaryResponse is the list/summary projection of a rule.
@@ -314,6 +465,13 @@ func (h *RulesHandler) Create(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to create rule")
return
}
if withLogic, err := h.applyLogic(&req, rule.ID); err != nil {
h.logger.Error("RulesHandler", "Create: persisting rule logic failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to save rule filter/actions")
return
} else if withLogic != nil {
rule = withLogic
}
emitAudit(h.auditService, r, audit.EventCreate, "Rule", rule.ID, "Create", true,
map[string]any{"name": rule.Name, "objectType": rule.ObjectType}, "")
WriteJSON(w, http.StatusCreated, ruleToDetailResponse(rule))
@@ -362,6 +520,13 @@ func (h *RulesHandler) Update(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to update rule")
return
}
if withLogic, err := h.applyLogic(&req, rule.ID); err != nil {
h.logger.Error("RulesHandler", "Update: persisting rule logic failed: %v", err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to save rule filter/actions")
return
} else if withLogic != nil {
rule = withLogic
}
emitAudit(h.auditService, r, audit.EventUpdate, "Rule", rule.ID, "Update", true,
map[string]any{"name": rule.Name}, "")
WriteJSON(w, http.StatusOK, ruleToDetailResponse(rule))
+123
View File
@@ -0,0 +1,123 @@
// Package api - rule editor metadata (vocabulary served to the UI)
package api
// OperatorMeta describes a condition operator for the editor.
type OperatorMeta struct {
Value string `json:"value"`
Label string `json:"label"`
NeedsValue bool `json:"needsValue"` // false for Exists/NotExists
Custom bool `json:"custom,omitempty"` // true for raw LDAP expression
Hint string `json:"hint,omitempty"`
}
// LabeledValue is a generic {value,label,description} tuple for dropdowns.
type LabeledValue struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
}
// AttributeMeta is a suggested attribute for a given object type.
type AttributeMeta struct {
Name string `json:"name"`
Label string `json:"label"`
}
// RuleMetadata is the full vocabulary the rule editor renders from.
type RuleMetadata struct {
ObjectTypes []LabeledValue `json:"objectTypes"`
SearchScopes []LabeledValue `json:"searchScopes"`
JoinOperators []LabeledValue `json:"joinOperators"`
Operators []OperatorMeta `json:"operators"`
ActionTypes []LabeledValue `json:"actionTypes"`
SyncModes []LabeledValue `json:"syncModes"`
ScheduleUnits []LabeledValue `json:"scheduleUnits"`
CommonAttributes map[string][]AttributeMeta `json:"commonAttributes"`
}
func ruleMetadata() RuleMetadata {
return RuleMetadata{
ObjectTypes: []LabeledValue{
{Value: "User", Label: "Users"},
{Value: "Computer", Label: "Computers"},
{Value: "Group", Label: "Groups"},
},
SearchScopes: []LabeledValue{
{Value: "Subtree", Label: "This container and everything below"},
{Value: "OneLevel", Label: "Immediate children only"},
{Value: "Base", Label: "This object only"},
},
JoinOperators: []LabeledValue{
{Value: "AND", Label: "Match ALL of", Description: "Every condition must be true"},
{Value: "OR", Label: "Match ANY of", Description: "At least one condition must be true"},
},
Operators: []OperatorMeta{
{Value: "Equals", Label: "equals", NeedsValue: true},
{Value: "NotEquals", Label: "does not equal", NeedsValue: true},
{Value: "Contains", Label: "contains", NeedsValue: true},
{Value: "StartsWith", Label: "starts with", NeedsValue: true},
{Value: "EndsWith", Label: "ends with", NeedsValue: true},
{Value: "GreaterThan", Label: "is greater than or equal to", NeedsValue: true},
{Value: "LessThan", Label: "is less than or equal to", NeedsValue: true},
{Value: "Exists", Label: "is present", NeedsValue: false},
{Value: "NotExists", Label: "is not present", NeedsValue: false},
{Value: "MemberOf", Label: "is a member of (direct)", NeedsValue: true, Hint: "Group DN"},
{Value: "MemberOfRecursive", Label: "is a member of (incl. nested)", NeedsValue: true, Hint: "Group DN"},
{Value: "CustomLdap", Label: "raw LDAP filter", NeedsValue: true, Custom: true, Hint: "e.g. (department=Sales)"},
},
ActionTypes: []LabeledValue{
{Value: "SyncGroupMembership", Label: "Sync membership to group", Description: "Keep a target group's members in step with the matched set (add + remove)"},
{Value: "MoveToOu", Label: "Move to OU", Description: "Move each matched object into a target OU"},
{Value: "EnsureGroupExists", Label: "Ensure group exists", Description: "Create the target group if it is missing"},
{Value: "AddToGroup", Label: "Add to group (no removal)", Description: "Add matched objects to a group without ever removing anyone"},
},
SyncModes: []LabeledValue{
{Value: "FullSync", Label: "Full sync (add + remove)", Description: "Membership becomes exactly the matched set; members that no longer match are removed, including manual additions"},
{Value: "ManagedAdd", Label: "Managed (add + remove only what we added)", Description: "Add matches and remove only members this rule added; leaves manually-added members alone"},
{Value: "AddOnly", Label: "Add only", Description: "Only add matching members; never remove automatically"},
},
ScheduleUnits: []LabeledValue{
{Value: "Minutes", Label: "minutes"},
{Value: "Hours", Label: "hours"},
{Value: "Days", Label: "days"},
},
CommonAttributes: map[string][]AttributeMeta{
"User": {
{Name: "sAMAccountName", Label: "Logon name (sAMAccountName)"},
{Name: "userPrincipalName", Label: "User principal name (UPN)"},
{Name: "mail", Label: "Email"},
{Name: "displayName", Label: "Display name"},
{Name: "givenName", Label: "First name"},
{Name: "sn", Label: "Last name"},
{Name: "department", Label: "Department"},
{Name: "title", Label: "Job title"},
{Name: "company", Label: "Company"},
{Name: "physicalDeliveryOfficeName", Label: "Office"},
{Name: "l", Label: "City"},
{Name: "st", Label: "State/Province"},
{Name: "co", Label: "Country"},
{Name: "manager", Label: "Manager (DN)"},
{Name: "employeeType", Label: "Employee type"},
{Name: "description", Label: "Description"},
{Name: "memberOf", Label: "Member of (group DN)"},
{Name: "userAccountControl", Label: "Account control flags"},
},
"Computer": {
{Name: "cn", Label: "Name"},
{Name: "dNSHostName", Label: "DNS host name"},
{Name: "operatingSystem", Label: "Operating system"},
{Name: "operatingSystemVersion", Label: "OS version"},
{Name: "description", Label: "Description"},
{Name: "memberOf", Label: "Member of (group DN)"},
},
"Group": {
{Name: "cn", Label: "Name"},
{Name: "sAMAccountName", Label: "Group name (sAMAccountName)"},
{Name: "description", Label: "Description"},
{Name: "groupType", Label: "Group type flags"},
{Name: "mail", Label: "Email"},
{Name: "memberOf", Label: "Member of (group DN)"},
},
},
}
}
+148
View File
@@ -0,0 +1,148 @@
// Package api - TLS configuration handlers (certificate mode, bring-your-own
// upload, and Windows-store selection). Settings persist to app_settings so the
// UI choice overrides the environment, and the live TLS manager is reloaded so
// changes take effect without a restart.
package api
import (
"net/http"
"github.com/Grace-Solutions/OrchestrAD/internal/audit"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/services"
"github.com/Grace-Solutions/OrchestrAD/internal/tlsmgr"
)
// TLSHandler handles /api/v1/tls endpoints. manager may be nil when TLS is
// disabled (plain HTTP behind a proxy).
type TLSHandler struct {
settings *services.SettingsService
manager *tlsmgr.Manager
auditService *audit.Service
logger *logging.Logger
}
// NewTLSHandler creates a TLSHandler.
func NewTLSHandler(settings *services.SettingsService, manager *tlsmgr.Manager, auditService *audit.Service, logger *logging.Logger) *TLSHandler {
return &TLSHandler{settings: settings, manager: manager, auditService: auditService, logger: logger}
}
type tlsStatusResponse struct {
Enabled bool `json:"enabled"`
WindowsSupported bool `json:"windowsStoreSupported"`
Certificate *tlsmgr.Info `json:"certificate,omitempty"`
}
// Status handles GET /api/v1/tls/status
func (h *TLSHandler) Status(w http.ResponseWriter, r *http.Request) {
resp := tlsStatusResponse{
Enabled: h.manager != nil,
WindowsSupported: tlsmgr.WindowsStoreSupported(),
}
if h.manager != nil {
info := h.manager.CurrentInfo()
resp.Certificate = &info
}
WriteJSON(w, http.StatusOK, resp)
}
type tlsModeRequest struct {
Mode string `json:"mode"`
WindowsThumbprint string `json:"windowsThumbprint,omitempty"`
}
// SetMode handles POST /api/v1/tls/mode
func (h *TLSHandler) SetMode(w http.ResponseWriter, r *http.Request) {
if h.manager == nil {
WriteError(w, http.StatusConflict, ErrCodeValidation, "TLS is disabled; enable it before configuring a certificate mode")
return
}
var req tlsModeRequest
if err := DecodeJSON(r, &req); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
switch req.Mode {
case tlsmgr.ModeAuto, tlsmgr.ModeProvided, tlsmgr.ModeWindowsStore:
default:
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "mode must be auto, provided, or windows-store")
return
}
if req.Mode == tlsmgr.ModeWindowsStore {
if !tlsmgr.WindowsStoreSupported() {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "the Windows certificate store is only available on Windows")
return
}
if req.WindowsThumbprint == "" {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "windowsThumbprint is required for windows-store mode")
return
}
if err := h.settings.Upsert(services.Setting{Key: "tls.windows_thumbprint", Value: req.WindowsThumbprint, ValueType: "string"}); err != nil {
h.internalError(w, "persisting thumbprint", err)
return
}
}
if err := h.settings.Upsert(services.Setting{Key: "tls.mode", Value: req.Mode, ValueType: "string"}); err != nil {
h.internalError(w, "persisting mode", err)
return
}
h.reloadAndRespond(w, r, "SetMode", req.Mode)
}
type tlsUploadRequest struct {
CertificatePEM string `json:"certificatePem"`
PrivateKeyPEM string `json:"privateKeyPem"`
ChainPEM string `json:"chainPem,omitempty"`
}
// UploadCertificate handles POST /api/v1/tls/certificate (bring-your-own).
func (h *TLSHandler) UploadCertificate(w http.ResponseWriter, r *http.Request) {
if h.manager == nil {
WriteError(w, http.StatusConflict, ErrCodeValidation, "TLS is disabled; enable it before uploading a certificate")
return
}
var req tlsUploadRequest
if err := DecodeJSON(r, &req); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
return
}
if req.CertificatePEM == "" || req.PrivateKeyPEM == "" {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "certificatePem and privateKeyPem are required")
return
}
if err := h.manager.SaveProvided([]byte(req.CertificatePEM), []byte(req.PrivateKeyPEM), []byte(req.ChainPEM)); err != nil {
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "Invalid certificate/key: "+err.Error())
return
}
if err := h.settings.Upsert(services.Setting{Key: "tls.mode", Value: tlsmgr.ModeProvided, ValueType: "string"}); err != nil {
h.internalError(w, "persisting mode", err)
return
}
h.reloadAndRespond(w, r, "UploadCertificate", tlsmgr.ModeProvided)
}
// WindowsCerts handles GET /api/v1/tls/windows-store
func (h *TLSHandler) WindowsCerts(w http.ResponseWriter, r *http.Request) {
certs, err := tlsmgr.ListWindowsCerts()
if err != nil {
WriteError(w, http.StatusNotImplemented, ErrCodeInternalError, err.Error())
return
}
WriteJSON(w, http.StatusOK, certs)
}
func (h *TLSHandler) reloadAndRespond(w http.ResponseWriter, r *http.Request, action, mode string) {
if err := h.manager.Reload(); err != nil {
emitAudit(h.auditService, r, audit.EventConfigChange, "TLS", mode, action, false, nil, err.Error())
h.internalError(w, "reloading TLS", err)
return
}
info := h.manager.CurrentInfo()
emitAudit(h.auditService, r, audit.EventConfigChange, "TLS", mode, action, true, map[string]any{"mode": mode, "fallback": info.Fallback}, "")
WriteJSON(w, http.StatusOK, info)
}
func (h *TLSHandler) internalError(w http.ResponseWriter, ctx string, err error) {
h.logger.Error("TLSHandler", "%s failed: %v", ctx, err)
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "TLS operation failed")
}
+144 -4
View File
@@ -20,6 +20,7 @@ var (
ErrSessionRevoked = errors.New("session has been revoked")
ErrPasswordTooShort = errors.New("new password is too short")
ErrPasswordUnchanged = errors.New("new password must differ from the current password")
ErrUsernameTaken = errors.New("a different account already uses this username")
)
// MinPasswordLength is the enforced minimum length for passwords set through
@@ -72,7 +73,13 @@ func (s *Service) Login(username, password string) (*LoginResult, error) {
return nil, ErrInvalidCredentials
}
// Create session
return s.createSession(user)
}
// createSession issues a new session for an already-authenticated user, updates
// last-login, loads roles, and returns the session token. Shared by local and
// OIDC login.
func (s *Service) createSession(user *models.User) (*LoginResult, error) {
sessionToken, err := crypto.GenerateRandomKey(32)
if err != nil {
return nil, err
@@ -91,10 +98,7 @@ func (s *Service) Login(username, password string) (*LoginResult, error) {
return nil, err
}
// Update last login
s.userRepo.UpdateLastLogin(user.ID)
// Load user roles
user.Roles, _ = s.GetUserRoles(user.ID)
return &LoginResult{
@@ -104,6 +108,89 @@ func (s *Service) Login(username, password string) (*LoginResult, error) {
}, nil
}
// OIDCIdentity carries the claims extracted from a validated ID token.
type OIDCIdentity struct {
ProviderID string
Subject string
Username string
Email string
DisplayName string
DefaultRole string // role name to grant a newly provisioned user (optional)
}
// LoginOIDC signs in a federated user. It links by the stable (provider,
// subject) pair; a first-time subject provisions a new active OIDC user (no
// local password). To avoid silent account takeover it refuses to reuse a
// username already held by a different (e.g. local) account.
func (s *Service) LoginOIDC(id OIDCIdentity) (*LoginResult, error) {
if id.Subject == "" || id.Username == "" {
return nil, ErrInvalidCredentials
}
user, err := s.userRepo.GetByOIDCSubject(id.ProviderID, id.Subject)
if err != nil {
return nil, err
}
if user != nil {
if !user.IsActive {
return nil, ErrUserDisabled
}
return s.createSession(user)
}
// First login for this subject: the username must not already belong to a
// different account.
if existing, err := s.userRepo.GetByUsername(id.Username); err != nil {
return nil, err
} else if existing != nil {
return nil, ErrUsernameTaken
}
newUser := &models.User{
Username: id.Username,
Email: strOrNil(id.Email),
DisplayName: strOrNil(id.DisplayName),
IsActive: true,
IsOIDCUser: true,
OIDCProviderID: strOrNil(id.ProviderID),
OIDCSubject: strOrNil(id.Subject),
}
if err := s.userRepo.Create(newUser); err != nil {
return nil, err
}
if id.DefaultRole != "" {
if err := s.assignRoleByName(newUser.ID, id.DefaultRole); err != nil {
// Non-fatal: the user is created but without the default role.
_ = err
}
}
return s.createSession(newUser)
}
// assignRoleByName grants the named role to a user, if the role exists.
func (s *Service) assignRoleByName(userID, roleName string) error {
var roleID string
err := s.db.QueryRow(`SELECT id FROM roles WHERE name = ?`, roleName).Scan(&roleID)
if err == sql.ErrNoRows {
return nil
}
if err != nil {
return err
}
_, err = s.db.Exec(`
INSERT OR IGNORE INTO user_roles (user_id, role_id, created_utc)
VALUES (?, ?, ?)
`, userID, roleID, time.Now().UTC().Format(time.RFC3339))
return err
}
func strOrNil(s string) *string {
if s == "" {
return nil
}
return &s
}
// ValidateSession validates a session token and returns the user
func (s *Service) ValidateSession(token string) (*models.User, error) {
tokenHash := crypto.HashAPIKey(token)
@@ -147,6 +234,59 @@ func (s *Service) ValidateSession(token string) (*models.User, error) {
return user, nil
}
// ValidateAPIKey resolves an API key (as sent in the X-API-Key header) to its
// owning user and scope ("read" or "readwrite"), enforcing the key's
// enabled/revoked/expiry state, and best-effort stamps last_used_utc. Mirrors
// ValidateSession for the API-key auth path.
func (s *Service) ValidateAPIKey(token string) (*models.User, string, error) {
keyHash := crypto.HashAPIKey(token)
var id, userID string
var isEnabled int
var expiresStr, revokedStr, scopeStr sql.NullString
err := s.db.QueryRow(`
SELECT id, user_id, is_enabled, expires_utc, revoked_utc, scope
FROM api_keys WHERE key_hash = ?
`, keyHash).Scan(&id, &userID, &isEnabled, &expiresStr, &revokedStr, &scopeStr)
if err == sql.ErrNoRows {
return nil, "", ErrInvalidCredentials
}
if err != nil {
return nil, "", err
}
if revokedStr.Valid {
return nil, "", ErrSessionRevoked
}
if isEnabled == 0 {
return nil, "", ErrSessionRevoked
}
if expiresStr.Valid && expiresStr.String != "" {
if expires, perr := time.Parse(time.RFC3339, expiresStr.String); perr == nil && time.Now().UTC().After(expires) {
return nil, "", ErrSessionExpired
}
}
user, err := s.userRepo.GetByID(userID)
if err != nil {
return nil, "", err
}
if user == nil {
return nil, "", ErrUserNotFound
}
if !user.IsActive {
return nil, "", ErrUserDisabled
}
_, _ = s.db.Exec(`UPDATE api_keys SET last_used_utc = ? WHERE id = ?`, time.Now().UTC().Format(time.RFC3339), id)
user.Roles, _ = s.GetUserRoles(user.ID)
scope := scopeStr.String
if scope == "" {
scope = "readwrite"
}
return user, scope, nil
}
// ChangePassword verifies the user's current password and, on success,
// replaces it with newPassword while clearing the password_reset_required
// flag atomically through UserRepository.UpdatePassword.
+77
View File
@@ -0,0 +1,77 @@
package auth
import (
"database/sql"
"path/filepath"
"testing"
"github.com/Grace-Solutions/OrchestrAD/internal/config"
"github.com/Grace-Solutions/OrchestrAD/internal/db"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
)
func migratedDB(t *testing.T) *sql.DB {
t.Helper()
database, err := db.New(config.DatabaseConfig{
Path: filepath.Join(t.TempDir(), "oidc_test.db"),
MaxOpenConns: 1,
MaxIdleConns: 1,
WALMode: true,
ForeignKeys: true,
}, logging.Default())
if err != nil {
t.Fatalf("db.New: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
return database.Conn()
}
func TestLoginOIDCProvisionsAndLinksBySubject(t *testing.T) {
svc := NewService(migratedDB(t))
id := OIDCIdentity{ProviderID: "https://idp", Subject: "sub-1", Username: "alice", Email: "alice@example.com", DisplayName: "Alice"}
first, err := svc.LoginOIDC(id)
if err != nil {
t.Fatalf("first LoginOIDC: %v", err)
}
if first.User.Username != "alice" || !first.User.IsOIDCUser || first.User.PasswordHash != nil {
t.Fatalf("provisioned user looks wrong: %+v", first.User)
}
if first.SessionToken == "" {
t.Fatal("no session token issued")
}
// Same subject, even with a changed username, resolves to the same user.
id2 := id
id2.Username = "alice-renamed"
second, err := svc.LoginOIDC(id2)
if err != nil {
t.Fatalf("second LoginOIDC: %v", err)
}
if second.User.ID != first.User.ID {
t.Errorf("subject not linked: got user %s, want %s", second.User.ID, first.User.ID)
}
}
func TestLoginOIDCRefusesUsernameCollision(t *testing.T) {
conn := migratedDB(t)
svc := NewService(conn)
// A pre-existing local account owns the username.
hash := "x"
if err := repository.NewUserRepository(conn).Create(&models.User{
Username: "bob", PasswordHash: &hash, IsActive: true,
}); err != nil {
t.Fatalf("seed local user: %v", err)
}
_, err := svc.LoginOIDC(OIDCIdentity{ProviderID: "https://idp", Subject: "sub-bob", Username: "bob"})
if err != ErrUsernameTaken {
t.Fatalf("expected ErrUsernameTaken, got %v", err)
}
}
+39 -1
View File
@@ -19,6 +19,7 @@ import (
"github.com/Grace-Solutions/OrchestrAD/internal/scheduler"
"github.com/Grace-Solutions/OrchestrAD/internal/server"
"github.com/Grace-Solutions/OrchestrAD/internal/services"
"github.com/Grace-Solutions/OrchestrAD/internal/tlsmgr"
)
// RunInit initializes the application: validates config, initializes DB, runs migrations
@@ -90,6 +91,9 @@ func runServer(ctx context.Context) error {
return fmt.Errorf("bootstrapping admin user: %w", err)
}
// Seed the built-in schedules so operators have ready-made cadences.
services.EnsureDefaultSchedules(database.Conn(), logger)
// Derive a 32-byte AES key from the configured secret key
keyHash := sha256.Sum256(cfg.SecretKey)
encryptor, err := crypto.NewEncryptor(keyHash[:])
@@ -101,6 +105,34 @@ func runServer(ctx context.Context) error {
connService := services.NewConnectionService(database.Conn(), encryptor, logger)
ruleRunner := runner.New(database.Conn(), connService, logger)
auditService := audit.NewService(database.Conn())
settingsService := services.NewSettingsService(database.Conn(), logger)
// TLS: on by default (self-managed CA + leaf, auto-renewed, exported under
// <data>/tls). Disable via the tls.enabled setting or ORCHESTRAD_TLS_ENABLED
// to serve plain HTTP behind a TLS-terminating proxy. A UI value wins over
// the env var.
var tlsManager *tlsmgr.Manager
if settingsService.ResolveBool("tls.enabled", "ORCHESTRAD_TLS_ENABLED", true) {
tlsManager = tlsmgr.New(
filepath.Join(cfg.DataPath, "tls"),
tlsmgr.Options{},
func() tlsmgr.Config {
return tlsmgr.Config{
Mode: settingsService.ResolveString("tls.mode", "ORCHESTRAD_TLS_MODE", tlsmgr.ModeAuto),
PFXPassword: settingsService.ResolveString("tls.pfx_password", "ORCHESTRAD_TLS_PFX_PASSWORD", "orchestrad"),
WindowsThumbprint: settingsService.ResolveString("tls.windows_thumbprint", "ORCHESTRAD_TLS_WINDOWS_THUMBPRINT", ""),
}
},
logger,
)
if err := tlsManager.Ensure(); err != nil {
return fmt.Errorf("initializing TLS: %w", err)
}
go tlsManager.Start(ctx)
} else {
logger.Info("TLS", "TLS disabled; serving plain HTTP (expecting TLS termination upstream)")
}
deps := server.Dependencies{
Runner: ruleRunner,
Engine: engine.NewEngine(logger),
@@ -116,9 +148,11 @@ func runServer(ctx context.Context) error {
UserRepo: repository.NewUserRepository(database.Conn()),
APIKeyService: services.NewAPIKeyService(database.Conn(), logger),
BackupService: services.NewBackupService(database, filepath.Join(cfg.DataPath, "backups"), 10, logger),
SettingsService: services.NewSettingsService(database.Conn(), logger),
SettingsService: settingsService,
DashboardService: services.NewDashboardService(database.Conn(), logger),
ActivityService: services.NewActivityService(database.Conn(), logger),
ConfigService: services.NewConfigService(database.Conn(), logger),
TLS: tlsManager,
}
// Record a service-start audit event so the trail is bootstrapped
@@ -134,6 +168,10 @@ func runServer(ctx context.Context) error {
}
defer sched.Stop()
// Start background database maintenance (history retention + VACUUM) so the
// database does not grow forever. Bound to the run context.
services.NewMaintenanceService(database.Conn(), cfg.Maintenance, logger).Start(ctx)
// Create and run the HTTP server. srv.Run blocks until ctx is cancelled,
// which happens on an interactive interrupt or a service stop request.
srv := server.New(cfg, database, deps, logger)
+57 -7
View File
@@ -15,9 +15,19 @@ type Config struct {
SecretKey []byte
Server ServerConfig
Database DatabaseConfig
Logging LoggingConfig
OIDC OIDCConfig
CORS CORSConfig
Logging LoggingConfig
OIDC OIDCConfig
CORS CORSConfig
Maintenance MaintenanceConfig
}
// MaintenanceConfig controls background history pruning and database compaction
// so the database does not grow without bound.
type MaintenanceConfig struct {
RunRetentionDays int // rule_runs (+ their actions) older than this are deleted
AuditRetentionDays int // audit_events older than this are deleted
IntervalHours int // how often maintenance runs
Vacuum bool // run VACUUM after pruning to reclaim space
}
// ServerConfig holds HTTP server settings
@@ -85,9 +95,16 @@ func Load() (*Config, error) {
DataPath: dataPath,
SecretKey: secretKey,
Server: ServerConfig{
Host: getEnv("ORCHESTRAD_HOST", "0.0.0.0"),
Port: getEnvInt("ORCHESTRAD_PORT", 8080),
TrustedProxies: splitCSV(getEnv("ORCHESTRAD_TRUSTED_PROXIES", "")),
// Listen address/port precedence: environment variable, then the
// value the Windows installer recorded in the registry, then the
// built-in default. (On non-Windows registrySetting returns "".)
Host: getEnv("ORCHESTRAD_HOST", firstNonEmpty(registrySetting("ListenAddress"), "0.0.0.0")),
Port: getEnvInt("ORCHESTRAD_PORT", atoiOr(registrySetting("ListenPort"), 18090)),
// Trust reverse proxies in local/private ranges by default so
// X-Forwarded-* headers (client IP, scheme, host) are honored out
// of the box behind an edge proxy. Override with an explicit CIDR
// list, the keywords "local"/"private"/"all"/"none", or "" to disable.
TrustedProxies: splitCSV(getEnv("ORCHESTRAD_TRUSTED_PROXIES", "local")),
},
Database: DatabaseConfig{
Path: filepath.Join(dataPath, "orchestrad.db"),
@@ -100,7 +117,7 @@ func Load() (*Config, error) {
Logging: LoggingConfig{
Level: getEnv("ORCHESTRAD_LOG_LEVEL", "info"),
FilePath: filepath.Join(dataPath, "logs", "orchestrad.log"),
MaxSizeMB: getEnvInt("ORCHESTRAD_LOG_MAX_SIZE_MB", 100),
MaxSizeMB: getEnvInt("ORCHESTRAD_LOG_MAX_SIZE_MB", 5),
MaxBackups: getEnvInt("ORCHESTRAD_LOG_MAX_BACKUPS", 3),
MaxAgeDays: getEnvInt("ORCHESTRAD_LOG_MAX_AGE_DAYS", 30),
Compress: true,
@@ -110,6 +127,12 @@ func Load() (*Config, error) {
AllowedOrigins: corsOrigins(),
AllowCredentials: true,
},
Maintenance: MaintenanceConfig{
RunRetentionDays: getEnvInt("ORCHESTRAD_RUN_RETENTION_DAYS", 90),
AuditRetentionDays: getEnvInt("ORCHESTRAD_AUDIT_RETENTION_DAYS", 180),
IntervalHours: getEnvInt("ORCHESTRAD_MAINTENANCE_INTERVAL_HOURS", 24),
Vacuum: getEnvBool("ORCHESTRAD_MAINTENANCE_VACUUM", true),
},
}
return cfg, nil
@@ -170,3 +193,30 @@ func getEnvInt(key string, defaultVal int) int {
}
return defaultVal
}
func getEnvBool(key string, defaultVal bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "":
return defaultVal
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// firstNonEmpty returns v if it is non-empty, otherwise def.
func firstNonEmpty(v, def string) string {
if v != "" {
return v
}
return def
}
// atoiOr parses v as an int, returning def when v is empty or unparseable.
func atoiOr(v string, def int) int {
if i, err := strconv.Atoi(v); err == nil {
return i
}
return def
}
+2 -2
View File
@@ -20,8 +20,8 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Server.Host != "0.0.0.0" {
t.Errorf("Host = %q, want 0.0.0.0", cfg.Server.Host)
}
if cfg.Server.Port != 8080 {
t.Errorf("Port = %d, want 8080", cfg.Server.Port)
if cfg.Server.Port != 18090 {
t.Errorf("Port = %d, want 18090", cfg.Server.Port)
}
if cfg.Logging.Level != "info" {
t.Errorf("Log level = %q, want info", cfg.Logging.Level)
@@ -0,0 +1,6 @@
//go:build !windows
package config
// registrySetting is a no-op off Windows.
func registrySetting(string) string { return "" }
@@ -0,0 +1,21 @@
//go:build windows
package config
import "golang.org/x/sys/windows/registry"
// registrySetting reads a value the MSI recorded under
// HKLM\Software\Grace Solutions\OrchestrAD (e.g. ListenAddress, ListenPort).
// Returns "" when the key or value is absent.
func registrySetting(name string) string {
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Grace Solutions\OrchestrAD`, registry.QUERY_VALUE)
if err != nil {
return ""
}
defer k.Close()
v, _, err := k.GetStringValue(name)
if err != nil {
return ""
}
return v
}
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_managed_group_members_rule_group;
DROP TABLE IF EXISTS managed_group_members;
@@ -0,0 +1,15 @@
-- Tracks group memberships that OrchestrAD added via a SyncGroupMembership
-- action, so the "ManagedAdd" sync mode can remove only what it added and
-- leave hand-added members alone. FullSync does not depend on this table for
-- correctness, but rows are still maintained for both modes so an operator can
-- switch a rule's sync mode without losing ownership history.
CREATE TABLE IF NOT EXISTS managed_group_members (
rule_id TEXT NOT NULL,
group_dn TEXT NOT NULL,
member_dn TEXT NOT NULL,
added_utc TEXT NOT NULL,
PRIMARY KEY (rule_id, group_dn, member_dn)
);
CREATE INDEX IF NOT EXISTS idx_managed_group_members_rule_group
ON managed_group_members (rule_id, group_dn);
@@ -0,0 +1 @@
ALTER TABLE api_keys DROP COLUMN scope;
@@ -0,0 +1,4 @@
-- API keys carry a scope controlling what they may do: "read" (GET only) or
-- "readwrite" (full access). Existing keys default to readwrite to preserve
-- current behavior.
ALTER TABLE api_keys ADD COLUMN scope TEXT NOT NULL DEFAULT 'readwrite';
@@ -0,0 +1,190 @@
package ldap
import (
"strings"
ldap "github.com/go-ldap/ldap/v3"
)
// CanonicalName returns an operator-friendly canonical name for an object in
// the form domain.com/OU/OU/CN. It prefers the directory-provided canonicalName
// (an AD constructed attribute) when present, and otherwise builds one from the
// distinguished name.
func CanonicalName(dn, provided string) string {
if p := strings.TrimSpace(provided); p != "" {
return p
}
return canonicalFromDN(dn)
}
// canonicalFromDN converts a distinguished name to a canonical name:
// the DC components become the dotted domain, and the remaining RDNs (OU/CN)
// are reversed to root-first and joined with "/". For example
//
// CN=Jane,OU=Users,OU=HQ,DC=corp,DC=example,DC=com
//
// becomes corp.example.com/HQ/Users/Jane. If the DN cannot be parsed it is
// returned unchanged.
func canonicalFromDN(dn string) string {
parsed, err := ldap.ParseDN(dn)
if err != nil {
return dn
}
var domain []string
var path []string
for _, rdn := range parsed.RDNs {
for _, a := range rdn.Attributes {
if strings.EqualFold(a.Type, "DC") {
domain = append(domain, a.Value)
} else {
path = append(path, a.Value)
}
}
}
// RDNs are leaf-first; canonical path is root-first.
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
path[i], path[j] = path[j], path[i]
}
dom := strings.Join(domain, ".")
switch {
case dom == "" && len(path) == 0:
return dn
case len(path) == 0:
return dom
case dom == "":
return strings.Join(path, "/")
default:
return dom + "/" + strings.Join(path, "/")
}
}
// LooksCanonical reports whether s is a canonical name (domain.com/OU/OU) rather
// than a distinguished name. A DN contains "=" in its components; a canonical
// name uses "/" separators and no "=".
func LooksCanonical(s string) bool {
s = strings.TrimSpace(s)
return s != "" && strings.Contains(s, "/") && !strings.Contains(s, "=")
}
// CanonicalToOUDN converts a canonical OU path (domain.com/OU/OU) into a
// distinguished name, treating every path segment as an organizationalUnit
// (the common case for a target OU). For example
//
// corp.example.com/HQ/Users
//
// becomes OU=Users,OU=HQ,DC=corp,DC=example,DC=com.
func CanonicalToOUDN(canonical string) string {
canonical = strings.Trim(strings.TrimSpace(canonical), "/")
if canonical == "" {
return ""
}
parts := strings.Split(canonical, "/")
domain := parts[0]
path := parts[1:] // root-first
var comps []string
for i := len(path) - 1; i >= 0; i-- { // reverse to leaf-first
comps = append(comps, "OU="+escapeDNValue(path[i]))
}
for _, d := range strings.Split(domain, ".") {
if d != "" {
comps = append(comps, "DC="+escapeDNValue(d))
}
}
return strings.Join(comps, ",")
}
// NormalizeOUTarget accepts either a DN or a canonical OU path and returns a DN.
func NormalizeOUTarget(input string) string {
if LooksCanonical(input) {
return CanonicalToOUDN(input)
}
return strings.TrimSpace(input)
}
// CanonicalToLeafDN converts a canonical path (domain.com/OU/.../Leaf) into a DN
// where the final segment is a CN leaf (a group, user, or computer) and every
// intermediate segment is an organizationalUnit. For example
//
// corp.example.com/HQ/Groups/All Staff
//
// becomes CN=All Staff,OU=Groups,OU=HQ,DC=corp,DC=example,DC=com.
func CanonicalToLeafDN(canonical string) string {
canonical = strings.Trim(strings.TrimSpace(canonical), "/")
if canonical == "" {
return ""
}
parts := strings.Split(canonical, "/")
domain := parts[0]
path := parts[1:] // root-first, last element is the CN leaf
if len(path) == 0 {
// Only a domain was supplied; nothing to anchor a leaf on.
return ""
}
var comps []string
comps = append(comps, "CN="+escapeDNValue(path[len(path)-1]))
for i := len(path) - 2; i >= 0; i-- { // OU path, leaf-first
comps = append(comps, "OU="+escapeDNValue(path[i]))
}
for _, d := range strings.Split(domain, ".") {
if d != "" {
comps = append(comps, "DC="+escapeDNValue(d))
}
}
return strings.Join(comps, ",")
}
// NormalizeGroupTarget accepts either a DN or a canonical path to a group and
// returns a DN. Unlike NormalizeOUTarget, a canonical path's final segment is
// treated as the group's CN, not an OU.
func NormalizeGroupTarget(input string) string {
if LooksCanonical(input) {
return CanonicalToLeafDN(input)
}
return strings.TrimSpace(input)
}
// escapeDNValue escapes the characters that are special inside a DN attribute
// value (RFC 4514), enough for OU/DC names entered by operators.
func escapeDNValue(v string) string {
r := strings.NewReplacer(
`\`, `\\`,
`,`, `\,`,
`+`, `\+`,
`"`, `\"`,
`<`, `\<`,
`>`, `\>`,
`;`, `\;`,
`=`, `\=`,
)
return r.Replace(v)
}
// splitDNComponents splits a DN into its top-level RDN components, honoring
// backslash-escaped commas.
func splitDNComponents(dn string) []string {
var out []string
var cur strings.Builder
escaped := false
for _, ch := range dn {
switch {
case escaped:
cur.WriteRune(ch)
escaped = false
case ch == '\\':
cur.WriteRune(ch)
escaped = true
case ch == ',':
out = append(out, cur.String())
cur.Reset()
default:
cur.WriteRune(ch)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
@@ -0,0 +1,93 @@
package ldap
import "testing"
func TestCanonicalFromDN(t *testing.T) {
cases := []struct{ dn, want string }{
{"CN=OrchestrAD,OU=Users,OU=GSL,DC=gracesolutions,DC=lab", "gracesolutions.lab/GSL/Users/OrchestrAD"},
{"CN=Jane Doe,OU=Users,OU=HQ,DC=corp,DC=example,DC=com", "corp.example.com/HQ/Users/Jane Doe"},
{"DC=gracesolutions,DC=lab", "gracesolutions.lab"},
{"OU=Engineering,DC=corp,DC=local", "corp.local/Engineering"},
}
for _, c := range cases {
if got := canonicalFromDN(c.dn); got != c.want {
t.Errorf("canonicalFromDN(%q) = %q, want %q", c.dn, got, c.want)
}
}
}
func TestCanonicalNamePrefersProvided(t *testing.T) {
dn := "CN=OrchestrAD,OU=Users,DC=gracesolutions,DC=lab"
if got := CanonicalName(dn, "gracesolutions.lab/Users/OrchestrAD"); got != "gracesolutions.lab/Users/OrchestrAD" {
t.Errorf("provided canonicalName should win, got %q", got)
}
// Falls back to constructing from the DN when not provided.
if got := CanonicalName(dn, ""); got != "gracesolutions.lab/Users/OrchestrAD" {
t.Errorf("constructed = %q", got)
}
if got := CanonicalName(dn, " "); got != "gracesolutions.lab/Users/OrchestrAD" {
t.Errorf("blank provided should fall back, got %q", got)
}
}
func TestCanonicalToOUDN(t *testing.T) {
cases := []struct{ canonical, want string }{
{"corp.example.com/HQ/Users", "OU=Users,OU=HQ,DC=corp,DC=example,DC=com"},
{"gracesolutions.lab/GSL", "OU=GSL,DC=gracesolutions,DC=lab"},
{"gracesolutions.lab/GSL/Sub/Deep", "OU=Deep,OU=Sub,OU=GSL,DC=gracesolutions,DC=lab"},
{"gracesolutions.lab", "DC=gracesolutions,DC=lab"},
}
for _, c := range cases {
if got := CanonicalToOUDN(c.canonical); got != c.want {
t.Errorf("CanonicalToOUDN(%q) = %q, want %q", c.canonical, got, c.want)
}
}
}
func TestNormalizeOUTarget(t *testing.T) {
// canonical -> DN
if got := NormalizeOUTarget("corp.example.com/HQ"); got != "OU=HQ,DC=corp,DC=example,DC=com" {
t.Errorf("canonical normalize = %q", got)
}
// a DN passes through unchanged
dn := "OU=HQ,DC=corp,DC=example,DC=com"
if got := NormalizeOUTarget(dn); got != dn {
t.Errorf("DN should pass through, got %q", got)
}
if !LooksCanonical("corp.example.com/HQ") || LooksCanonical("OU=HQ,DC=corp,DC=com") {
t.Error("LooksCanonical misclassified an input")
}
}
func TestSplitDNComponentsEscaped(t *testing.T) {
got := splitDNComponents(`OU=Eng\,Ops,OU=HQ,DC=corp,DC=com`)
want := []string{`OU=Eng\,Ops`, "OU=HQ", "DC=corp", "DC=com"}
if len(got) != len(want) {
t.Fatalf("split = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("component %d = %q, want %q", i, got[i], want[i])
}
}
}
func TestCanonicalToLeafDN(t *testing.T) {
got := CanonicalToLeafDN("corp.example.com/HQ/Groups/All Staff")
want := "CN=All Staff,OU=Groups,OU=HQ,DC=corp,DC=example,DC=com"
if got != want {
t.Errorf("CanonicalToLeafDN = %q, want %q", got, want)
}
// A group directly under the domain root.
if got := CanonicalToLeafDN("corp.example.com/AllStaff"); got != "CN=AllStaff,DC=corp,DC=example,DC=com" {
t.Errorf("root-level leaf = %q", got)
}
// NormalizeGroupTarget: canonical converts, DN passes through.
if got := NormalizeGroupTarget("corp.example.com/Groups/G"); got != "CN=G,OU=Groups,DC=corp,DC=example,DC=com" {
t.Errorf("NormalizeGroupTarget canonical = %q", got)
}
dn := "CN=G,OU=Groups,DC=corp,DC=com"
if got := NormalizeGroupTarget(dn); got != dn {
t.Errorf("NormalizeGroupTarget DN passthrough = %q", got)
}
}
+23
View File
@@ -127,6 +127,29 @@ func (c *Client) Search(baseDN string, scope int, filter string, attributes []st
return result.Entries, nil
}
// SearchWithLimit performs a non-paged search bounded by sizeLimit, tolerating
// the server's size-limit-exceeded response by returning the partial results.
// Used for bounded lookups (schema browsing, distinct-value sampling) where a
// full paged sweep would be wasteful.
func (c *Client) SearchWithLimit(baseDN string, scope int, filter string, attributes []string, sizeLimit int) ([]*ldap.Entry, error) {
if c.conn == nil {
return nil, fmt.Errorf("not connected")
}
req := ldap.NewSearchRequest(
baseDN, scope, ldap.NeverDerefAliases,
sizeLimit, int(c.config.Timeout.Seconds()), false,
filter, attributes, nil,
)
res, err := c.conn.Search(req)
if err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultSizeLimitExceeded) && res != nil {
return res.Entries, nil
}
return nil, fmt.Errorf("search failed: %w", err)
}
return res.Entries, nil
}
// SearchOne performs a search expecting exactly one result
func (c *Client) SearchOne(baseDN string, scope int, filter string, attributes []string) (*ldap.Entry, error) {
entries, err := c.Search(baseDN, scope, filter, attributes)
@@ -55,8 +55,20 @@ func (c *Condition) BuildFilter() string {
filter = fmt.Sprintf("(%s>=%s)", c.Attribute, escapeLDAPValue(c.Value))
case types.OperatorLessThan:
filter = fmt.Sprintf("(%s<=%s)", c.Attribute, escapeLDAPValue(c.Value))
case types.OperatorMemberOf:
// Direct membership: value is the group DN.
filter = fmt.Sprintf("(memberOf=%s)", escapeLDAPValue(c.Value))
case types.OperatorMemberOfRecursive:
// Nested (transitive) membership via LDAP_MATCHING_RULE_IN_CHAIN.
filter = fmt.Sprintf("(memberOf:1.2.840.113556.1.4.1941:=%s)", escapeLDAPValue(c.Value))
case types.OperatorCustomLdap:
filter = c.CustomLdap
case types.OperatorRegex:
// LDAP has no regular-expression matching, so a true regex cannot be
// pushed into the server-side filter. Rather than silently degrade to an
// equality match, require the object to at least have the attribute and
// leave regex out of the advertised operator set (see rules metadata).
filter = fmt.Sprintf("(%s=*)", c.Attribute)
default:
filter = fmt.Sprintf("(%s=%s)", c.Attribute, escapeLDAPValue(c.Value))
}
@@ -137,6 +149,12 @@ func BuildRuleFilter(objectType types.ObjectType, groups []ConditionGroup, group
return fmt.Sprintf("(&%s%s)", objectFilter, conditionFilter)
}
// EscapeFilterValue escapes special characters in a value destined for an LDAP
// filter (RFC 4515), for callers building filters outside this package.
func EscapeFilterValue(s string) string {
return escapeLDAPValue(s)
}
// escapeLDAPValue escapes special characters in LDAP filter values
func escapeLDAPValue(s string) string {
replacements := []struct {
@@ -135,6 +135,36 @@ func (c *Client) CreateOU(ouDN string) error {
return c.conn.Add(addRequest)
}
// EnsureOUPath idempotently ensures every organizationalUnit along ouDN exists,
// creating missing OUs from the domain root down to the leaf. The non-OU suffix
// (the DC domain components and any CN containers) is assumed to already exist.
// Safe to call repeatedly.
func (c *Client) EnsureOUPath(ouDN string) error {
if c.conn == nil {
return fmt.Errorf("not connected")
}
comps := splitDNComponents(ouDN)
// OU components, root-most (highest index) first, so parents are created
// before their children.
for i := len(comps) - 1; i >= 0; i-- {
if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(comps[i])), "OU=") {
continue
}
dn := strings.Join(comps[i:], ",")
exists, err := c.Exists(dn)
if err != nil {
return fmt.Errorf("checking OU %s: %w", dn, err)
}
if !exists {
if err := c.CreateOU(dn); err != nil {
return fmt.Errorf("creating OU %s: %w", dn, err)
}
}
}
return nil
}
// Exists checks if an object exists
func (c *Client) Exists(dn string) (bool, error) {
entry, err := c.SearchOne(dn, ldap.ScopeBaseObject, "(objectClass=*)", []string{"dn"})
+5
View File
@@ -122,6 +122,11 @@ type ActionConfig struct {
// RemoveFromGroupIfNoMatch
RemoveIfNoMatch bool `json:"removeIfNoMatch,omitempty"`
// SyncGroupMembership: how to reconcile the target group's membership
// against the matched set. One of FullSync, ManagedAdd, AddOnly. Empty
// defaults to FullSync.
SyncMode string `json:"syncMode,omitempty"`
// Dynamic path variables
UseDynamicPath bool `json:"useDynamicPath,omitempty"`
PathTemplate string `json:"pathTemplate,omitempty"`
+131
View File
@@ -0,0 +1,131 @@
package pki
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
// Export writes the full chain to dir in PEM, DER, and PKCS#12 form. Every call
// overwrites the files, so it is safe to call on each renewal to keep the
// exports current. Private-key files are written 0600.
//
// Files written:
//
// root.crt / root.key root CA (PEM)
// intermediate.crt / intermediate.key intermediate CA (PEM)
// server.crt / server.key leaf (PEM)
// fullchain.pem leaf + intermediate + root (PEM)
// *.cer DER encodings of each certificate
// server.pfx leaf key + leaf cert + CA chain (PKCS#12)
func (c *Chain) Export(dir, pfxPassword string) error {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("creating tls dir: %w", err)
}
writes := []struct {
name string
data []byte
mode os.FileMode
}{
{"root.crt", certPEM(c.Root.DER), 0o644},
{"root.key", keyPEM(c.Root.Key), 0o600},
{"intermediate.crt", certPEM(c.Intermediate.DER), 0o644},
{"intermediate.key", keyPEM(c.Intermediate.Key), 0o600},
{"server.crt", certPEM(c.Leaf.DER), 0o644},
{"server.key", keyPEM(c.Leaf.Key), 0o600},
{"fullchain.pem", c.FullChainPEM(), 0o644},
// DER encodings.
{"root.cer", c.Root.DER, 0o644},
{"intermediate.cer", c.Intermediate.DER, 0o644},
{"server.cer", c.Leaf.DER, 0o644},
}
for _, w := range writes {
if err := os.WriteFile(filepath.Join(dir, w.name), w.data, w.mode); err != nil {
return fmt.Errorf("writing %s: %w", w.name, err)
}
}
// PKCS#12 bundle: leaf key + leaf cert + CA chain (intermediate, root).
pfx, err := pkcs12.Modern.Encode(
c.Leaf.Key,
c.Leaf.Certificate,
[]*x509.Certificate{c.Intermediate.Certificate, c.Root.Certificate},
pfxPassword,
)
if err != nil {
return fmt.Errorf("encoding pfx: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "server.pfx"), pfx, 0o600); err != nil {
return fmt.Errorf("writing server.pfx: %w", err)
}
return nil
}
// LoadCA reads the root and intermediate CA (cert + key) previously written by
// Export from dir, so renewals can re-issue leaves under the same chain.
// Returns (nil, nil, nil) when the CA files are not present yet.
func LoadCA(dir string) (root, intermediate *CertKey, err error) {
rootCrt := filepath.Join(dir, "root.crt")
if _, statErr := os.Stat(rootCrt); os.IsNotExist(statErr) {
return nil, nil, nil
}
root, err = loadCertKey(filepath.Join(dir, "root.crt"), filepath.Join(dir, "root.key"))
if err != nil {
return nil, nil, fmt.Errorf("loading root CA: %w", err)
}
intermediate, err = loadCertKey(filepath.Join(dir, "intermediate.crt"), filepath.Join(dir, "intermediate.key"))
if err != nil {
return nil, nil, fmt.Errorf("loading intermediate CA: %w", err)
}
return root, intermediate, nil
}
func loadCertKey(certPath, keyPath string) (*CertKey, error) {
certBytes, err := os.ReadFile(certPath)
if err != nil {
return nil, err
}
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
}
cert, err := ParseCertPEM(certBytes)
if err != nil {
return nil, err
}
key, err := ParseKeyPEM(keyBytes)
if err != nil {
return nil, err
}
return &CertKey{Certificate: cert, DER: cert.Raw, Key: key}, nil
}
// ParseCertPEM parses the first CERTIFICATE block from PEM bytes.
func ParseCertPEM(b []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(b)
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("no CERTIFICATE PEM block found")
}
return x509.ParseCertificate(block.Bytes)
}
// ParseKeyPEM parses an RSA private key from a PKCS#8 or PKCS#1 PEM block.
func ParseKeyPEM(b []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(b)
if block == nil {
return nil, fmt.Errorf("no PRIVATE KEY PEM block found")
}
if k, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
if rk, ok := k.(*rsa.PrivateKey); ok {
return rk, nil
}
return nil, fmt.Errorf("private key is not RSA")
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}
+198
View File
@@ -0,0 +1,198 @@
// Package pki implements OrchestrAD's self-managed certificate authority: it
// generates a Root CA, an Intermediate CA, and a server (leaf) certificate, and
// exports the material to disk in PEM, DER, and PKCS#12 (.pfx) form. The CA is
// persisted so leaves can be renewed under the same chain of trust; only the
// leaf is re-issued on renewal.
package pki
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"time"
)
const (
rootValidity = 10 * 365 * 24 * time.Hour
intermediateValidity = 5 * 365 * 24 * time.Hour
// DefaultLeafValidity is the default lifetime of an issued server cert.
DefaultLeafValidity = 397 * 24 * time.Hour
rootKeyBits = 4096
intermediateKeyBits = 4096
leafKeyBits = 2048
)
// CertKey bundles a parsed certificate, its DER encoding, and its RSA private
// key.
type CertKey struct {
Certificate *x509.Certificate
DER []byte
Key *rsa.PrivateKey
}
// Chain is a full Root -> Intermediate -> Leaf certificate chain.
type Chain struct {
Root *CertKey
Intermediate *CertKey
Leaf *CertKey
}
func serialNumber() (*big.Int, error) {
limit := new(big.Int).Lsh(big.NewInt(1), 128)
return rand.Int(rand.Reader, limit)
}
// NewCA generates a fresh Root CA and Intermediate CA. The intermediate is
// path-length constrained so it can only issue leaf certificates.
func NewCA(rootCN, intermediateCN string) (root, intermediate *CertKey, err error) {
now := time.Now().UTC().Add(-5 * time.Minute)
// Root
rootKey, err := rsa.GenerateKey(rand.Reader, rootKeyBits)
if err != nil {
return nil, nil, fmt.Errorf("root key: %w", err)
}
rootSerial, err := serialNumber()
if err != nil {
return nil, nil, err
}
rootTmpl := &x509.Certificate{
SerialNumber: rootSerial,
Subject: pkix.Name{CommonName: rootCN, Organization: []string{"OrchestrAD"}},
NotBefore: now,
NotAfter: now.Add(rootValidity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1,
}
rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootKey.PublicKey, rootKey)
if err != nil {
return nil, nil, fmt.Errorf("root cert: %w", err)
}
rootCert, err := x509.ParseCertificate(rootDER)
if err != nil {
return nil, nil, err
}
root = &CertKey{Certificate: rootCert, DER: rootDER, Key: rootKey}
// Intermediate, signed by root
intKey, err := rsa.GenerateKey(rand.Reader, intermediateKeyBits)
if err != nil {
return nil, nil, fmt.Errorf("intermediate key: %w", err)
}
intSerial, err := serialNumber()
if err != nil {
return nil, nil, err
}
intTmpl := &x509.Certificate{
SerialNumber: intSerial,
Subject: pkix.Name{CommonName: intermediateCN, Organization: []string{"OrchestrAD"}},
NotBefore: now,
NotAfter: now.Add(intermediateValidity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 0,
MaxPathLenZero: true,
}
intDER, err := x509.CreateCertificate(rand.Reader, intTmpl, rootCert, &intKey.PublicKey, rootKey)
if err != nil {
return nil, nil, fmt.Errorf("intermediate cert: %w", err)
}
intCert, err := x509.ParseCertificate(intDER)
if err != nil {
return nil, nil, err
}
intermediate = &CertKey{Certificate: intCert, DER: intDER, Key: intKey}
return root, intermediate, nil
}
// IssueLeaf issues a server certificate signed by the intermediate CA for the
// given common name and SANs. It is used both for first issuance and renewal.
func IssueLeaf(intermediate *CertKey, cn string, dnsNames []string, ips []net.IP, validity time.Duration) (*CertKey, error) {
if validity <= 0 {
validity = DefaultLeafValidity
}
now := time.Now().UTC().Add(-5 * time.Minute)
leafKey, err := rsa.GenerateKey(rand.Reader, leafKeyBits)
if err != nil {
return nil, fmt.Errorf("leaf key: %w", err)
}
serial, err := serialNumber()
if err != nil {
return nil, err
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: cn, Organization: []string{"OrchestrAD"}},
NotBefore: now,
NotAfter: now.Add(validity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: dnsNames,
IPAddresses: ips,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, intermediate.Certificate, &leafKey.PublicKey, intermediate.Key)
if err != nil {
return nil, fmt.Errorf("leaf cert: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, err
}
return &CertKey{Certificate: cert, DER: der, Key: leafKey}, nil
}
// GenerateChain creates a full Root -> Intermediate -> Leaf chain in one call.
func GenerateChain(cn string, dnsNames []string, ips []net.IP, leafValidity time.Duration) (*Chain, error) {
root, intermediate, err := NewCA("OrchestrAD Root CA", "OrchestrAD Intermediate CA")
if err != nil {
return nil, err
}
leaf, err := IssueLeaf(intermediate, cn, dnsNames, ips, leafValidity)
if err != nil {
return nil, err
}
return &Chain{Root: root, Intermediate: intermediate, Leaf: leaf}, nil
}
// certPEM / keyPEM helpers.
func certPEM(der []byte) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}
func keyPEM(key *rsa.PrivateKey) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8(key)})
}
func pkcs8(key *rsa.PrivateKey) []byte {
b, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
// RSA keys always marshal; fall back to PKCS1 defensively.
return x509.MarshalPKCS1PrivateKey(key)
}
return b
}
// FullChainPEM returns leaf + intermediate + root concatenated in PEM order
// (leaf first), suitable for a server fullchain file.
func (c *Chain) FullChainPEM() []byte {
out := append([]byte{}, certPEM(c.Leaf.DER)...)
out = append(out, certPEM(c.Intermediate.DER)...)
out = append(out, certPEM(c.Root.DER)...)
return out
}
// TLSCertificate builds a tls.Certificate (leaf + intermediate chain) for use by
// the HTTPS server.
func (c *Chain) TLSCertificate() (leafDER, intDER []byte, key *rsa.PrivateKey) {
return c.Leaf.DER, c.Intermediate.DER, c.Leaf.Key
}
+117
View File
@@ -0,0 +1,117 @@
package pki
import (
"crypto/x509"
"net"
"os"
"path/filepath"
"testing"
"time"
pkcs12 "software.sslmate.com/src/go-pkcs12"
)
func TestGenerateChainVerifies(t *testing.T) {
chain, err := GenerateChain("orchestrad.local", []string{"orchestrad.local", "localhost"}, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour)
if err != nil {
t.Fatalf("GenerateChain: %v", err)
}
// Leaf must chain to the root through the intermediate.
roots := x509.NewCertPool()
roots.AddCert(chain.Root.Certificate)
inter := x509.NewCertPool()
inter.AddCert(chain.Intermediate.Certificate)
if _, err := chain.Leaf.Certificate.Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: inter,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}); err != nil {
t.Fatalf("leaf does not verify to root via intermediate: %v", err)
}
// SANs present.
if err := chain.Leaf.Certificate.VerifyHostname("orchestrad.local"); err != nil {
t.Errorf("hostname SAN missing: %v", err)
}
if !chain.Root.Certificate.IsCA || !chain.Intermediate.Certificate.IsCA {
t.Error("CA certificates must have IsCA set")
}
if chain.Leaf.Certificate.IsCA {
t.Error("leaf must not be a CA")
}
}
func TestExportAndReload(t *testing.T) {
dir := t.TempDir()
chain, err := GenerateChain("orchestrad.local", []string{"localhost"}, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour)
if err != nil {
t.Fatalf("GenerateChain: %v", err)
}
if err := chain.Export(dir, "orchestrad"); err != nil {
t.Fatalf("Export: %v", err)
}
// All expected files exist.
for _, name := range []string{
"root.crt", "root.key", "intermediate.crt", "intermediate.key",
"server.crt", "server.key", "fullchain.pem",
"root.cer", "intermediate.cer", "server.cer", "server.pfx",
} {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Errorf("expected export %s: %v", name, err)
}
}
// The PFX decodes back to the leaf key + cert + CA chain.
pfx, err := os.ReadFile(filepath.Join(dir, "server.pfx"))
if err != nil {
t.Fatalf("read pfx: %v", err)
}
key, cert, caCerts, err := pkcs12.DecodeChain(pfx, "orchestrad")
if err != nil {
t.Fatalf("decode pfx: %v", err)
}
if key == nil || cert == nil {
t.Fatal("pfx missing key or cert")
}
if cert.Subject.CommonName != "orchestrad.local" {
t.Errorf("pfx cert CN = %q, want orchestrad.local", cert.Subject.CommonName)
}
if len(caCerts) != 2 {
t.Errorf("pfx CA chain = %d certs, want 2 (intermediate + root)", len(caCerts))
}
// LoadCA reconstructs the CA so renewal can re-issue a leaf.
root, inter, err := LoadCA(dir)
if err != nil {
t.Fatalf("LoadCA: %v", err)
}
if root == nil || inter == nil {
t.Fatal("LoadCA returned nil for an existing CA")
}
newLeaf, err := IssueLeaf(inter, "orchestrad.local", []string{"localhost"}, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour)
if err != nil {
t.Fatalf("re-issue leaf from loaded CA: %v", err)
}
roots := x509.NewCertPool()
roots.AddCert(root.Certificate)
interPool := x509.NewCertPool()
interPool.AddCert(inter.Certificate)
if _, err := newLeaf.Certificate.Verify(x509.VerifyOptions{Roots: roots, Intermediates: interPool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil {
t.Fatalf("renewed leaf does not verify under loaded CA: %v", err)
}
}
// LoadCA on an empty dir returns nils (not an error) so first-run generation is
// triggered.
func TestLoadCAAbsent(t *testing.T) {
root, inter, err := LoadCA(t.TempDir())
if err != nil {
t.Fatalf("LoadCA on empty dir: %v", err)
}
if root != nil || inter != nil {
t.Error("expected nil CA for an empty directory")
}
}
+19 -7
View File
@@ -52,6 +52,7 @@ func (r *ConnectionRepository) GetByID(id string) (*models.ADConnection, error)
conn := &models.ADConnection{}
var isEnabled, useTLS, useStartTLS, allowInvalidCerts, pagingEnabled int
var lastTested, deleted sql.NullString
var createdStr, updatedStr string
err := r.db.QueryRow(`
SELECT id, name, description, is_enabled, hosts, port,
@@ -67,7 +68,7 @@ func (r *ConnectionRepository) GetByID(id string) (*models.ADConnection, error)
&conn.RootDN, &conn.BindDN, &conn.CredentialID, &conn.DefaultSearchScope,
&conn.TimeoutSeconds, &pagingEnabled, &conn.PageSize,
&lastTested, &conn.LastTestResult,
&conn.CreatedUTC, &conn.UpdatedUTC, &deleted,
&createdStr, &updatedStr, &deleted,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -82,6 +83,8 @@ func (r *ConnectionRepository) GetByID(id string) (*models.ADConnection, error)
conn.AllowInvalidCerts = intToBool(allowInvalidCerts)
conn.PagingEnabled = intToBool(pagingEnabled)
conn.LastTestedUTC = parseNullTime(lastTested)
conn.CreatedUTC = parseTimeOrZero(createdStr)
conn.UpdatedUTC = parseTimeOrZero(updatedStr)
conn.DeletedUTC = parseNullTime(deleted)
return conn, nil
}
@@ -96,9 +99,11 @@ func (r *ConnectionRepository) List(offset, limit int) ([]models.ADConnection, i
rows, err := r.db.Query(`
SELECT id, name, description, is_enabled, hosts, port,
use_tls, root_dn, credential_id, default_search_scope,
use_tls, use_start_tls, allow_invalid_certs,
root_dn, bind_dn, credential_id, default_search_scope,
timeout_seconds, paging_enabled, page_size,
last_tested_utc, last_test_result, created_utc, updated_utc
FROM ad_connections
FROM ad_connections
WHERE deleted_utc IS NULL
ORDER BY created_utc DESC
LIMIT ? OFFSET ?
@@ -111,20 +116,27 @@ func (r *ConnectionRepository) List(offset, limit int) ([]models.ADConnection, i
var conns []models.ADConnection
for rows.Next() {
var conn models.ADConnection
var isEnabled, useTLS int
var isEnabled, useTLS, useStartTLS, allowInvalidCerts, pagingEnabled int
var lastTested sql.NullString
var createdStr, updatedStr string
if err := rows.Scan(
&conn.ID, &conn.Name, &conn.Description, &isEnabled,
&conn.Hosts, &conn.Port, &useTLS, &conn.RootDN,
&conn.CredentialID, &conn.DefaultSearchScope,
&conn.Hosts, &conn.Port, &useTLS, &useStartTLS, &allowInvalidCerts,
&conn.RootDN, &conn.BindDN, &conn.CredentialID, &conn.DefaultSearchScope,
&conn.TimeoutSeconds, &pagingEnabled, &conn.PageSize,
&lastTested, &conn.LastTestResult,
&conn.CreatedUTC, &conn.UpdatedUTC,
&createdStr, &updatedStr,
); err != nil {
return nil, 0, err
}
conn.IsEnabled = intToBool(isEnabled)
conn.UseTLS = intToBool(useTLS)
conn.UseStartTLS = intToBool(useStartTLS)
conn.AllowInvalidCerts = intToBool(allowInvalidCerts)
conn.PagingEnabled = intToBool(pagingEnabled)
conn.LastTestedUTC = parseNullTime(lastTested)
conn.CreatedUTC = parseTimeOrZero(createdStr)
conn.UpdatedUTC = parseTimeOrZero(updatedStr)
conns = append(conns, conn)
}
+8 -2
View File
@@ -46,6 +46,7 @@ func (r *CredentialRepository) GetByID(id string) (*models.Credential, error) {
cred := &models.Credential{}
var isEnabled int
var lastTested, deleted sql.NullString
var createdStr, updatedStr string
err := r.db.QueryRow(`
SELECT id, name, description, credential_type, username,
@@ -56,7 +57,7 @@ func (r *CredentialRepository) GetByID(id string) (*models.Credential, error) {
&cred.ID, &cred.Name, &cred.Description, &cred.CredentialType,
&cred.Username, &cred.EncryptedSecret, &isEnabled,
&lastTested, &cred.LastTestResult,
&cred.CreatedUTC, &cred.UpdatedUTC, &deleted,
&createdStr, &updatedStr, &deleted,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -67,6 +68,8 @@ func (r *CredentialRepository) GetByID(id string) (*models.Credential, error) {
cred.IsEnabled = intToBool(isEnabled)
cred.LastTestedUTC = parseNullTime(lastTested)
cred.CreatedUTC = parseTimeOrZero(createdStr)
cred.UpdatedUTC = parseTimeOrZero(updatedStr)
cred.DeletedUTC = parseNullTime(deleted)
return cred, nil
}
@@ -98,15 +101,18 @@ func (r *CredentialRepository) List(offset, limit int) ([]models.Credential, int
var cred models.Credential
var isEnabled int
var lastTested sql.NullString
var createdStr, updatedStr string
if err := rows.Scan(
&cred.ID, &cred.Name, &cred.Description, &cred.CredentialType,
&cred.Username, &isEnabled, &lastTested, &cred.LastTestResult,
&cred.CreatedUTC, &cred.UpdatedUTC,
&createdStr, &updatedStr,
); err != nil {
return nil, 0, err
}
cred.IsEnabled = intToBool(isEnabled)
cred.LastTestedUTC = parseNullTime(lastTested)
cred.CreatedUTC = parseTimeOrZero(createdStr)
cred.UpdatedUTC = parseTimeOrZero(updatedStr)
creds = append(creds, cred)
}
@@ -0,0 +1,57 @@
// Package repository - managed group membership tracking
package repository
import (
"database/sql"
"time"
)
// ManagedMemberRepository records which group memberships OrchestrAD added on
// behalf of a rule, so the ManagedAdd sync mode can remove only what it added.
type ManagedMemberRepository struct {
db *sql.DB
}
// NewManagedMemberRepository creates a new ManagedMemberRepository.
func NewManagedMemberRepository(db *sql.DB) *ManagedMemberRepository {
return &ManagedMemberRepository{db: db}
}
// List returns the member DNs this rule has recorded as added to groupDN.
func (r *ManagedMemberRepository) List(ruleID, groupDN string) ([]string, error) {
rows, err := r.db.Query(
`SELECT member_dn FROM managed_group_members WHERE rule_id = ? AND group_dn = ?`,
ruleID, groupDN)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var dn string
if err := rows.Scan(&dn); err != nil {
return nil, err
}
out = append(out, dn)
}
return out, rows.Err()
}
// Add records that ruleID added memberDN to groupDN. Idempotent.
func (r *ManagedMemberRepository) Add(ruleID, groupDN, memberDN string) error {
_, err := r.db.Exec(`
INSERT INTO managed_group_members (rule_id, group_dn, member_dn, added_utc)
VALUES (?, ?, ?, ?)
ON CONFLICT(rule_id, group_dn, member_dn) DO NOTHING`,
ruleID, groupDN, memberDN, time.Now().UTC().Format(time.RFC3339))
return err
}
// Remove clears the ownership record for a single membership. Idempotent.
func (r *ManagedMemberRepository) Remove(ruleID, groupDN, memberDN string) error {
_, err := r.db.Exec(
`DELETE FROM managed_group_members WHERE rule_id = ? AND group_dn = ? AND member_dn = ?`,
ruleID, groupDN, memberDN)
return err
}
+18 -7
View File
@@ -80,6 +80,7 @@ func (r *RuleRunRepository) Update(run *models.RuleRun) error {
func (r *RuleRunRepository) GetByID(id string) (*models.RuleRun, error) {
run := &models.RuleRun{}
var completed sql.NullString
var startedStr, createdStr string
err := r.db.QueryRow(`
SELECT id, rule_id, status, started_utc, completed_utc, duration_ms,
@@ -87,9 +88,9 @@ func (r *RuleRunRepository) GetByID(id string) (*models.RuleRun, error) {
error_message, execution_mode, triggered_by, created_utc
FROM rule_runs WHERE id = ?
`, id).Scan(
&run.ID, &run.RuleID, &run.Status, &run.StartedUTC, &completed, &run.DurationMS,
&run.ID, &run.RuleID, &run.Status, &startedStr, &completed, &run.DurationMS,
&run.ObjectsMatched, &run.ObjectsProcessed, &run.ActionsExecuted, &run.ActionsFailed,
&run.ErrorMessage, &run.ExecutionMode, &run.TriggeredBy, &run.CreatedUTC,
&run.ErrorMessage, &run.ExecutionMode, &run.TriggeredBy, &createdStr,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -98,6 +99,8 @@ func (r *RuleRunRepository) GetByID(id string) (*models.RuleRun, error) {
return nil, err
}
run.StartedUTC = parseTimeOrZero(startedStr)
run.CreatedUTC = parseTimeOrZero(createdStr)
run.CompletedUTC = parseNullTime(completed)
return run, nil
}
@@ -128,13 +131,16 @@ func (r *RuleRunRepository) ListByRule(ruleID string, offset, limit int) ([]mode
for rows.Next() {
var run models.RuleRun
var completed sql.NullString
var startedStr, createdStr string
if err := rows.Scan(
&run.ID, &run.RuleID, &run.Status, &run.StartedUTC, &completed, &run.DurationMS,
&run.ID, &run.RuleID, &run.Status, &startedStr, &completed, &run.DurationMS,
&run.ObjectsMatched, &run.ObjectsProcessed, &run.ActionsExecuted, &run.ActionsFailed,
&run.ErrorMessage, &run.ExecutionMode, &run.TriggeredBy, &run.CreatedUTC,
&run.ErrorMessage, &run.ExecutionMode, &run.TriggeredBy, &createdStr,
); err != nil {
return nil, 0, err
}
run.StartedUTC = parseTimeOrZero(startedStr)
run.CreatedUTC = parseTimeOrZero(createdStr)
run.CompletedUTC = parseNullTime(completed)
runs = append(runs, run)
}
@@ -167,13 +173,16 @@ func (r *RuleRunRepository) List(offset, limit int) ([]models.RuleRun, int, erro
for rows.Next() {
var run models.RuleRun
var completed sql.NullString
var startedStr, createdStr string
if err := rows.Scan(
&run.ID, &run.RuleID, &run.Status, &run.StartedUTC, &completed, &run.DurationMS,
&run.ID, &run.RuleID, &run.Status, &startedStr, &completed, &run.DurationMS,
&run.ObjectsMatched, &run.ObjectsProcessed, &run.ActionsExecuted, &run.ActionsFailed,
&run.ErrorMessage, &run.ExecutionMode, &run.TriggeredBy, &run.CreatedUTC,
&run.ErrorMessage, &run.ExecutionMode, &run.TriggeredBy, &createdStr,
); err != nil {
return nil, 0, err
}
run.StartedUTC = parseTimeOrZero(startedStr)
run.CreatedUTC = parseTimeOrZero(createdStr)
run.CompletedUTC = parseNullTime(completed)
runs = append(runs, run)
}
@@ -220,12 +229,14 @@ func (r *RuleRunRepository) ListActionsByRun(runID string) ([]models.RuleRunActi
var actions []models.RuleRunAction
for rows.Next() {
var a models.RuleRunAction
var createdStr string
if err := rows.Scan(
&a.ID, &a.RuleRunID, &a.RuleActionID, &a.ObjectDN, &a.ActionType,
&a.Status, &a.DetailsJSON, &a.ErrorMessage, &a.DurationMS, &a.CreatedUTC,
&a.Status, &a.DetailsJSON, &a.ErrorMessage, &a.DurationMS, &createdStr,
); err != nil {
return nil, err
}
a.CreatedUTC = parseTimeOrZero(createdStr)
actions = append(actions, a)
}
+71 -21
View File
@@ -3,6 +3,7 @@ package repository
import (
"database/sql"
"strings"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
@@ -50,6 +51,7 @@ func (r *RuleRepository) GetByID(id string) (*models.Rule, error) {
rule := &models.Rule{}
var isEnabled, stopOnError int
var lastRun, deleted sql.NullString
var createdStr, updatedStr string
err := r.db.QueryRow(`
SELECT id, name, description, is_enabled, ad_connection_id,
@@ -64,7 +66,7 @@ func (r *RuleRepository) GetByID(id string) (*models.Rule, error) {
&rule.SearchScopeOverride, &rule.ScheduleID, &rule.ExecutionMode,
&rule.GroupJoinOperator, &rule.MaxParallelism, &stopOnError,
&lastRun, &rule.LastRunResult,
&rule.CreatedUTC, &rule.UpdatedUTC, &deleted,
&createdStr, &updatedStr, &deleted,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -76,6 +78,8 @@ func (r *RuleRepository) GetByID(id string) (*models.Rule, error) {
rule.IsEnabled = intToBool(isEnabled)
rule.StopOnError = intToBool(stopOnError)
rule.LastRunUTC = parseNullTime(lastRun)
rule.CreatedUTC = parseTimeOrZero(createdStr)
rule.UpdatedUTC = parseTimeOrZero(updatedStr)
rule.DeletedUTC = parseNullTime(deleted)
// Load condition groups
@@ -94,6 +98,10 @@ func (r *RuleRepository) GetByID(id string) (*models.Rule, error) {
}
func (r *RuleRepository) getConditionGroups(ruleID string) ([]models.RuleConditionGroup, error) {
// Read all groups first and close the cursor before loading conditions.
// Loading conditions per-group while this cursor is still open would hold
// one pooled connection and acquire a second, which deadlocks when the
// pool is small; a single bulk conditions query avoids that entirely.
rows, err := r.db.Query(`
SELECT id, rule_id, name, is_enabled, join_operator,
sort_order, negate, created_utc, updated_utc
@@ -104,64 +112,91 @@ func (r *RuleRepository) getConditionGroups(ruleID string) ([]models.RuleConditi
if err != nil {
return nil, err
}
defer rows.Close()
var groups []models.RuleConditionGroup
groupIDs := []string{}
for rows.Next() {
var g models.RuleConditionGroup
var isEnabled, negate int
var createdStr, updatedStr string
if err := rows.Scan(
&g.ID, &g.RuleID, &g.Name, &isEnabled, &g.JoinOperator,
&g.SortOrder, &negate, &g.CreatedUTC, &g.UpdatedUTC,
&g.SortOrder, &negate, &createdStr, &updatedStr,
); err != nil {
rows.Close()
return nil, err
}
g.IsEnabled = intToBool(isEnabled)
g.Negate = intToBool(negate)
// Load conditions for this group
g.Conditions, err = r.getConditions(g.ID)
if err != nil {
return nil, err
}
g.CreatedUTC = parseTimeOrZero(createdStr)
g.UpdatedUTC = parseTimeOrZero(updatedStr)
groups = append(groups, g)
groupIDs = append(groupIDs, g.ID)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
return groups, rows.Err()
byGroup, err := r.getConditionsForGroups(groupIDs)
if err != nil {
return nil, err
}
for i := range groups {
groups[i].Conditions = byGroup[groups[i].ID]
}
return groups, nil
}
func (r *RuleRepository) getConditions(groupID string) ([]models.RuleCondition, error) {
rows, err := r.db.Query(`
// getConditionsForGroups loads the conditions for every group in one query and
// returns them keyed by condition_group_id, preserving sort order.
func (r *RuleRepository) getConditionsForGroups(groupIDs []string) (map[string][]models.RuleCondition, error) {
out := make(map[string][]models.RuleCondition, len(groupIDs))
if len(groupIDs) == 0 {
return out, nil
}
placeholders := make([]string, len(groupIDs))
args := make([]any, len(groupIDs))
for i, id := range groupIDs {
placeholders[i] = "?"
args[i] = id
}
query := `
SELECT id, condition_group_id, is_enabled, attribute_name,
operator, value_type, comparison_value, custom_ldap_expression,
negate, sort_order, case_sensitive, created_utc, updated_utc
FROM rule_conditions
WHERE condition_group_id = ? AND deleted_utc IS NULL
ORDER BY sort_order
`, groupID)
WHERE condition_group_id IN (` + strings.Join(placeholders, ",") + `) AND deleted_utc IS NULL
ORDER BY sort_order`
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var conditions []models.RuleCondition
for rows.Next() {
var c models.RuleCondition
var isEnabled, negate, caseSensitive int
var createdStr, updatedStr string
if err := rows.Scan(
&c.ID, &c.ConditionGroupID, &isEnabled, &c.AttributeName,
&c.Operator, &c.ValueType, &c.ComparisonValue, &c.CustomLdapExpression,
&negate, &c.SortOrder, &caseSensitive, &c.CreatedUTC, &c.UpdatedUTC,
&negate, &c.SortOrder, &caseSensitive, &createdStr, &updatedStr,
); err != nil {
return nil, err
}
c.IsEnabled = intToBool(isEnabled)
c.Negate = intToBool(negate)
c.CaseSensitive = intToBool(caseSensitive)
conditions = append(conditions, c)
c.CreatedUTC = parseTimeOrZero(createdStr)
c.UpdatedUTC = parseTimeOrZero(updatedStr)
out[c.ConditionGroupID] = append(out[c.ConditionGroupID], c)
}
return conditions, rows.Err()
return out, rows.Err()
}
// List retrieves rules with summary counts and without nested detail.
@@ -192,19 +227,22 @@ func (r *RuleRepository) List(offset, limit int) ([]models.Rule, int, error) {
var rule models.Rule
var isEnabled, stopOnError int
var lastRun sql.NullString
var createdStr, updatedStr string
if err := rows.Scan(
&rule.ID, &rule.Name, &rule.Description, &isEnabled,
&rule.ADConnectionID, &rule.ObjectType, &rule.BaseDNOverride,
&rule.SearchScopeOverride, &rule.ScheduleID, &rule.ExecutionMode,
&rule.GroupJoinOperator, &rule.MaxParallelism, &stopOnError,
&lastRun, &rule.LastRunResult,
&rule.CreatedUTC, &rule.UpdatedUTC,
&createdStr, &updatedStr,
); err != nil {
return nil, 0, err
}
rule.IsEnabled = intToBool(isEnabled)
rule.StopOnError = intToBool(stopOnError)
rule.LastRunUTC = parseNullTime(lastRun)
rule.CreatedUTC = parseTimeOrZero(createdStr)
rule.UpdatedUTC = parseTimeOrZero(updatedStr)
rules = append(rules, rule)
}
@@ -232,6 +270,15 @@ func (r *RuleRepository) Update(rule *models.Rule) error {
return err
}
// UpdateLastRun records the outcome of the most recent execution on the rule
// row, so the rules list can show when it last ran and how it went.
func (r *RuleRepository) UpdateLastRun(id string, runUTC time.Time, result string) error {
_, err := r.db.Exec(`
UPDATE rules SET last_run_utc = ?, last_run_result = ? WHERE id = ?
`, formatTime(runUTC), result, id)
return err
}
// UpdateEnabled toggles the enabled flag on a rule.
func (r *RuleRepository) UpdateEnabled(id string, enabled bool) error {
now := time.Now().UTC()
@@ -288,13 +335,16 @@ func (r *RuleRepository) getActions(ruleID string) ([]models.RuleAction, error)
for rows.Next() {
var a models.RuleAction
var isEnabled int
var createdStr, updatedStr string
if err := rows.Scan(
&a.ID, &a.RuleID, &a.ActionType, &isEnabled, &a.SortOrder,
&a.ConfigurationJSON, &a.RollbackMode, &a.CreatedUTC, &a.UpdatedUTC,
&a.ConfigurationJSON, &a.RollbackMode, &createdStr, &updatedStr,
); err != nil {
return nil, err
}
a.IsEnabled = intToBool(isEnabled)
a.CreatedUTC = parseTimeOrZero(createdStr)
a.UpdatedUTC = parseTimeOrZero(updatedStr)
actions = append(actions, a)
}
@@ -0,0 +1,94 @@
// Package repository - rule filter/action authoring (condition groups,
// conditions, and actions). These are the write paths the rule editor uses to
// persist a rule's logic, complementing the scalar Create/Update in rules.go.
package repository
import (
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/google/uuid"
)
// ReplaceLogic atomically replaces a rule's condition groups (with their
// conditions) and actions. Existing rows are soft-deleted rather than removed
// so historical rule_run_actions keep their rule_action_id foreign key; new
// rows are inserted with fresh IDs. Passing empty slices clears the rule's
// logic.
func (r *RuleRepository) ReplaceLogic(ruleID string, groups []models.RuleConditionGroup, actions []models.RuleAction) error {
tx, err := r.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
now := formatTime(time.Now().UTC())
if _, err := tx.Exec(`UPDATE rule_condition_groups SET deleted_utc = ? WHERE rule_id = ? AND deleted_utc IS NULL`, now, ruleID); err != nil {
return err
}
if _, err := tx.Exec(`UPDATE rule_actions SET deleted_utc = ? WHERE rule_id = ? AND deleted_utc IS NULL`, now, ruleID); err != nil {
return err
}
for gi := range groups {
g := &groups[gi]
if g.ID == "" {
g.ID = uuid.New().String()
}
if g.JoinOperator == "" {
g.JoinOperator = "AND"
}
if g.SortOrder == 0 {
g.SortOrder = gi
}
if _, err := tx.Exec(`
INSERT INTO rule_condition_groups (id, rule_id, name, is_enabled, join_operator, sort_order, negate, created_utc, updated_utc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
g.ID, ruleID, g.Name, boolToInt(g.IsEnabled), g.JoinOperator, g.SortOrder, boolToInt(g.Negate), now, now); err != nil {
return err
}
for ci := range g.Conditions {
c := &g.Conditions[ci]
if c.ID == "" {
c.ID = uuid.New().String()
}
if c.ValueType == "" {
c.ValueType = "String"
}
if c.SortOrder == 0 {
c.SortOrder = ci
}
if _, err := tx.Exec(`
INSERT INTO rule_conditions (id, condition_group_id, is_enabled, attribute_name, operator, value_type,
comparison_value, custom_ldap_expression, negate, sort_order, case_sensitive, created_utc, updated_utc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, g.ID, boolToInt(c.IsEnabled), c.AttributeName, c.Operator, c.ValueType,
c.ComparisonValue, c.CustomLdapExpression, boolToInt(c.Negate), c.SortOrder,
boolToInt(c.CaseSensitive), now, now); err != nil {
return err
}
}
}
for ai := range actions {
a := &actions[ai]
if a.ID == "" {
a.ID = uuid.New().String()
}
if a.ConfigurationJSON == "" {
a.ConfigurationJSON = "{}"
}
if a.SortOrder == 0 {
a.SortOrder = ai
}
if _, err := tx.Exec(`
INSERT INTO rule_actions (id, rule_id, action_type, is_enabled, sort_order, configuration_json, rollback_mode, created_utc, updated_utc)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
a.ID, ruleID, a.ActionType, boolToInt(a.IsEnabled), a.SortOrder, a.ConfigurationJSON, a.RollbackMode, now, now); err != nil {
return err
}
}
return tx.Commit()
}
+8 -2
View File
@@ -48,6 +48,7 @@ func (r *ScheduleRepository) GetByID(id string) (*models.Schedule, error) {
sched := &models.Schedule{}
var isEnabled int
var nextRun, deleted sql.NullString
var createdStr, updatedStr string
err := r.db.QueryRow(`
SELECT id, name, description, is_enabled, schedule_kind,
@@ -58,7 +59,7 @@ func (r *ScheduleRepository) GetByID(id string) (*models.Schedule, error) {
&sched.ID, &sched.Name, &sched.Description, &isEnabled,
&sched.ScheduleKind, &sched.EasyIntervalValue, &sched.EasyIntervalUnit,
&sched.CronExpression, &sched.TimezoneMode, &nextRun,
&sched.CreatedUTC, &sched.UpdatedUTC, &deleted,
&createdStr, &updatedStr, &deleted,
)
if err == sql.ErrNoRows {
return nil, nil
@@ -69,6 +70,8 @@ func (r *ScheduleRepository) GetByID(id string) (*models.Schedule, error) {
sched.IsEnabled = intToBool(isEnabled)
sched.NextRunUTC = parseNullTime(nextRun)
sched.CreatedUTC = parseTimeOrZero(createdStr)
sched.UpdatedUTC = parseTimeOrZero(updatedStr)
sched.DeletedUTC = parseNullTime(deleted)
return sched, nil
}
@@ -100,16 +103,19 @@ func (r *ScheduleRepository) List(offset, limit int) ([]models.Schedule, int, er
var sched models.Schedule
var isEnabled int
var nextRun sql.NullString
var createdStr, updatedStr string
if err := rows.Scan(
&sched.ID, &sched.Name, &sched.Description, &isEnabled,
&sched.ScheduleKind, &sched.EasyIntervalValue, &sched.EasyIntervalUnit,
&sched.CronExpression, &sched.TimezoneMode, &nextRun,
&sched.CreatedUTC, &sched.UpdatedUTC,
&createdStr, &updatedStr,
); err != nil {
return nil, 0, err
}
sched.IsEnabled = intToBool(isEnabled)
sched.NextRunUTC = parseNullTime(nextRun)
sched.CreatedUTC = parseTimeOrZero(createdStr)
sched.UpdatedUTC = parseTimeOrZero(updatedStr)
scheds = append(scheds, sched)
}
@@ -0,0 +1,160 @@
package repository
import (
"database/sql"
"path/filepath"
"testing"
"github.com/Grace-Solutions/OrchestrAD/internal/config"
"github.com/Grace-Solutions/OrchestrAD/internal/db"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
)
// migratedDB spins up a temp-file database with the full schema applied.
func migratedDB(t *testing.T) *sql.DB {
t.Helper()
database, err := db.New(config.DatabaseConfig{
Path: filepath.Join(t.TempDir(), "repo_test.db"),
// A single connection guards the invariant that repository reads never
// hold one cursor open while opening another query on the pool (which
// would deadlock here). GetByID loads conditions in one bulk query
// rather than per-group, so this passes.
MaxOpenConns: 1,
MaxIdleConns: 1,
WALMode: true,
ForeignKeys: true,
}, logging.Default())
if err != nil {
t.Fatalf("db.New: %v", err)
}
t.Cleanup(func() { database.Close() })
if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
return database.Conn()
}
// TestListTimestampsParse guards the modernc.org/sqlite switch: unlike the CGO
// mattn driver, modernc returns TEXT timestamp columns as strings, so a Scan
// straight into time.Time fails ("unsupported Scan, storing driver.Value type
// string into type *time.Time"). This exercises the credential and connection
// List paths and asserts the timestamps come back parsed.
func TestListTimestampsParse(t *testing.T) {
conn := migratedDB(t)
credRepo := NewCredentialRepository(conn)
cred := &models.Credential{Name: "t-cred", CredentialType: "UsernamePassword", IsEnabled: true}
if err := credRepo.Create(cred); err != nil {
t.Fatalf("create credential: %v", err)
}
creds, _, err := credRepo.List(0, 50)
if err != nil {
t.Fatalf("list credentials: %v", err)
}
if len(creds) != 1 {
t.Fatalf("expected 1 credential, got %d", len(creds))
}
if creds[0].CreatedUTC.IsZero() || creds[0].UpdatedUTC.IsZero() {
t.Errorf("credential timestamps not parsed: created=%v updated=%v", creds[0].CreatedUTC, creds[0].UpdatedUTC)
}
connRepo := NewConnectionRepository(conn)
adc := &models.ADConnection{
Name: "t-conn", Hosts: "dc1", Port: 636, RootDN: "DC=x,DC=y",
UseTLS: true, UseStartTLS: true, AllowInvalidCerts: true,
DefaultSearchScope: "Subtree", TimeoutSeconds: 45,
PagingEnabled: true, PageSize: 500, IsEnabled: true,
}
if err := connRepo.Create(adc); err != nil {
t.Fatalf("create connection: %v", err)
}
conns, _, err := connRepo.List(0, 50)
if err != nil {
t.Fatalf("list connections: %v", err)
}
if len(conns) != 1 {
t.Fatalf("expected 1 connection, got %d", len(conns))
}
if conns[0].CreatedUTC.IsZero() || conns[0].UpdatedUTC.IsZero() {
t.Errorf("connection timestamps not parsed: created=%v updated=%v", conns[0].CreatedUTC, conns[0].UpdatedUTC)
}
// List must surface the full connection shape: the edit form pre-populates
// from the list row, so any field the List query drops gets silently
// overwritten with a zero value on the next save. Guard the fields the
// original List query omitted.
got := conns[0]
if !got.UseStartTLS || !got.AllowInvalidCerts || !got.PagingEnabled ||
got.TimeoutSeconds != 45 || got.PageSize != 500 {
t.Errorf("List dropped connection fields: useStartTls=%v allowInvalidCerts=%v pagingEnabled=%v timeout=%d pageSize=%d",
got.UseStartTLS, got.AllowInvalidCerts, got.PagingEnabled, got.TimeoutSeconds, got.PageSize)
}
}
// TestReplaceLogicRoundTrip verifies the rule editor's authoring path: a rule's
// condition groups and actions can be written via ReplaceLogic and read back
// through GetByID, and a second Replace supersedes the first.
func TestReplaceLogicRoundTrip(t *testing.T) {
conn := migratedDB(t)
// A connection to satisfy rules.ad_connection_id FK.
connRepo := NewConnectionRepository(conn)
adc := &models.ADConnection{
Name: "rl-conn", Hosts: "dc1", Port: 389, RootDN: "DC=x,DC=y",
DefaultSearchScope: "Subtree", TimeoutSeconds: 15, PageSize: 1000, IsEnabled: true,
}
if err := connRepo.Create(adc); err != nil {
t.Fatalf("create connection: %v", err)
}
ruleRepo := NewRuleRepository(conn)
rule := &models.Rule{Name: "rl-rule", ObjectType: "User", ADConnectionID: adc.ID, ExecutionMode: "Apply", GroupJoinOperator: "AND"}
if err := ruleRepo.Create(rule); err != nil {
t.Fatalf("create rule: %v", err)
}
val := "Sales"
groups := []models.RuleConditionGroup{{
JoinOperator: "AND", IsEnabled: true,
Conditions: []models.RuleCondition{{
IsEnabled: true, AttributeName: "department", Operator: "Equals", ComparisonValue: &val,
}},
}}
actions := []models.RuleAction{{
ActionType: "SyncGroupMembership", IsEnabled: true,
ConfigurationJSON: `{"targetGroupDn":"CN=Sales,DC=x,DC=y","syncMode":"FullSync"}`,
}}
if err := ruleRepo.ReplaceLogic(rule.ID, groups, actions); err != nil {
t.Fatalf("ReplaceLogic: %v", err)
}
got, err := ruleRepo.GetByID(rule.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if len(got.ConditionGroups) != 1 || len(got.ConditionGroups[0].Conditions) != 1 {
t.Fatalf("expected 1 group/1 condition, got %+v", got.ConditionGroups)
}
c := got.ConditionGroups[0].Conditions[0]
if c.AttributeName != "department" || c.Operator != "Equals" || c.ComparisonValue == nil || *c.ComparisonValue != "Sales" {
t.Errorf("condition round-trip wrong: %+v", c)
}
if len(got.Actions) != 1 || got.Actions[0].ActionType != "SyncGroupMembership" {
t.Fatalf("expected 1 sync action, got %+v", got.Actions)
}
// Replacing supersedes: swap to a single MoveToOu action and no conditions.
if err := ruleRepo.ReplaceLogic(rule.ID, nil, []models.RuleAction{{ActionType: "MoveToOu", IsEnabled: true, ConfigurationJSON: "{}"}}); err != nil {
t.Fatalf("ReplaceLogic 2: %v", err)
}
got2, err := ruleRepo.GetByID(rule.ID)
if err != nil {
t.Fatalf("GetByID 2: %v", err)
}
if len(got2.ConditionGroups) != 0 {
t.Errorf("expected groups cleared, got %d", len(got2.ConditionGroups))
}
if len(got2.Actions) != 1 || got2.Actions[0].ActionType != "MoveToOu" {
t.Errorf("expected only MoveToOu after replace, got %+v", got2.Actions)
}
}
+38
View File
@@ -116,6 +116,44 @@ func (r *UserRepository) GetByUsername(username string) (*models.User, error) {
return user, nil
}
// GetByOIDCSubject retrieves a non-deleted user by their OIDC provider id and
// subject, or nil when none matches. This is the stable identity link for
// federated (SSO) users.
func (r *UserRepository) GetByOIDCSubject(providerID, subject string) (*models.User, error) {
user := &models.User{}
var isActive, isOIDCUser, passwordResetRequired int
var lastLogin, deleted sql.NullString
var createdStr, updatedStr string
err := r.db.QueryRow(`
SELECT id, username, email, password_hash, display_name,
is_active, is_oidc_user, oidc_provider_id, oidc_subject,
password_reset_required,
last_login_utc, created_utc, updated_utc, deleted_utc
FROM users WHERE oidc_provider_id = ? AND oidc_subject = ? AND deleted_utc IS NULL
`, providerID, subject).Scan(
&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.DisplayName,
&isActive, &isOIDCUser, &user.OIDCProviderID, &user.OIDCSubject,
&passwordResetRequired,
&lastLogin, &createdStr, &updatedStr, &deleted,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
user.IsActive = intToBool(isActive)
user.IsOIDCUser = intToBool(isOIDCUser)
user.PasswordResetRequired = intToBool(passwordResetRequired)
user.LastLoginUTC = parseNullTime(lastLogin)
user.CreatedUTC = parseTimeOrZero(createdStr)
user.UpdatedUTC = parseTimeOrZero(updatedStr)
user.DeletedUTC = parseNullTime(deleted)
return user, nil
}
// List returns a page of non-deleted users ordered by username, along with
// the total count of matching rows for pagination metadata.
func (r *UserRepository) List(offset, limit int) ([]models.User, int, error) {
+13 -10
View File
@@ -77,6 +77,10 @@ func (e *actionExecutor) doAddToGroup(out *actionOutcome, cfg models.ActionConfi
return
}
if !exists {
if err := e.client.EnsureOUPath(parentDN(groupDN)); err != nil {
out.fail(fmt.Errorf("ensuring group OU path: %w", err))
return
}
if err := e.client.CreateGroup(groupDN, cfg.GroupType, cfg.GroupScope); err != nil {
out.fail(fmt.Errorf("creating group: %w", err))
return
@@ -113,6 +117,10 @@ func (e *actionExecutor) doEnsureGroupExists(out *actionOutcome, cfg models.Acti
return
}
if err := e.client.EnsureOUPath(parentDN(groupDN)); err != nil {
out.fail(fmt.Errorf("ensuring group OU path: %w", err))
return
}
if err := e.client.CreateGroup(groupDN, cfg.GroupType, cfg.GroupScope); err != nil {
out.fail(fmt.Errorf("creating group: %w", err))
return
@@ -128,24 +136,19 @@ func (e *actionExecutor) doMoveToOU(out *actionOutcome, cfg models.ActionConfig,
out.fail(fmt.Errorf("expanding target OU: %w", err))
return
}
// Accept either a DN or a canonical path (domain.com/OU/OU).
targetOU = ldap.NormalizeOUTarget(targetOU)
if targetOU == "" {
out.fail(fmt.Errorf("targetOu is required"))
return
}
if cfg.CreateOUIfMissing {
exists, err := e.client.Exists(targetOU)
if err != nil {
out.fail(fmt.Errorf("checking OU existence: %w", err))
// Idempotently create every OU down the path, not just the leaf.
if err := e.client.EnsureOUPath(targetOU); err != nil {
out.fail(fmt.Errorf("ensuring OU path: %w", err))
return
}
if !exists {
if err := e.client.CreateOU(targetOU); err != nil {
out.fail(fmt.Errorf("creating OU: %w", err))
return
}
out.Details["ouCreated"] = true
}
}
// If object is already in the target OU, nothing to do.
+121 -9
View File
@@ -15,7 +15,8 @@ import (
// Engine executes rules against Active Directory
type Engine struct {
logger *logging.Logger
logger *logging.Logger
managed ManagedMemberStore
}
// NewEngine creates a new rule engine
@@ -23,6 +24,19 @@ func NewEngine(logger *logging.Logger) *Engine {
return &Engine{logger: logger}
}
// SetManagedStore attaches the managed-membership store used by the ManagedAdd
// sync mode to remember which memberships this engine added. Optional; when
// unset, ManagedAdd never removes and FullSync is unaffected.
func (e *Engine) SetManagedStore(store ManagedMemberStore) {
e.managed = store
}
// isSetAction reports whether an action operates on the whole matched set at
// once (rather than per matched object).
func isSetAction(actionType string) bool {
return types.ActionType(actionType) == types.ActionSyncGroupMembership
}
// ExecutionResult contains the results of rule execution
type ExecutionResult struct {
RuleID string
@@ -77,9 +91,12 @@ type PreviewResult struct {
// MatchedObject represents an AD object that matched rule conditions
type MatchedObject struct {
DN string
ObjectType string
Attributes map[string][]string
DN string
// CanonicalName is the operator-friendly name (domain.com/OU/CN), from the
// directory's canonicalName attribute when present, else built from the DN.
CanonicalName string
ObjectType string
Attributes map[string][]string
}
// PlannedAction represents an action that would be executed
@@ -125,10 +142,11 @@ func (e *Engine) Preview(ctx context.Context, rule *models.Rule, conn *models.AD
for _, attr := range entry.Attributes {
matched.Attributes[attr.Name] = attr.Values
}
matched.CanonicalName = ldap.CanonicalName(entry.DN, entry.GetAttributeValue("canonicalName"))
result.MatchedObjects = append(result.MatchedObjects, matched)
}
result.PlannedActions = e.planActions(rule, result.MatchedObjects)
result.PlannedActions = e.planActions(rule, result.MatchedObjects, client)
e.logger.Info("RuleEngine", "Preview complete: %d objects matched, %d actions planned",
len(result.MatchedObjects), len(result.PlannedActions))
@@ -172,6 +190,7 @@ func (e *Engine) Execute(ctx context.Context, rule *models.Rule, conn *models.AD
executor := newActionExecutor(client)
cancelled := false
matchedDNs := make([]string, 0, len(entries))
objectLoop:
for _, entry := range entries {
@@ -191,11 +210,12 @@ objectLoop:
for _, attr := range entry.Attributes {
matched.Attributes[attr.Name] = attr.Values
}
matchedDNs = append(matchedDNs, entry.DN)
for i := range rule.Actions {
action := &rule.Actions[i]
if !action.IsEnabled {
continue
if !action.IsEnabled || isSetAction(action.ActionType) {
continue // set-level actions run once after the loop
}
actionStart := time.Now()
outcome := executor.execute(action, rule, matched)
@@ -225,6 +245,34 @@ objectLoop:
result.ObjectsProcessed++
}
// Set-level actions (SyncGroupMembership) reconcile the whole matched set
// against a target group's membership in one pass.
if !cancelled {
for i := range rule.Actions {
action := &rule.Actions[i]
if !action.IsEnabled || !isSetAction(action.ActionType) {
continue
}
actionStart := time.Now()
outcomes := e.reconcileMembership(action, rule, matchedDNs, client, e.managed)
dur := time.Since(actionStart)
for _, oc := range outcomes {
oc.Duration = dur
result.ActionResults = append(result.ActionResults, oc)
if oc.Success {
result.ActionsExecuted++
} else {
result.ActionsFailed++
result.Errors = append(result.Errors,
fmt.Sprintf("sync %s on %s failed: %s", oc.ActionType, oc.ObjectDN, oc.Error))
}
}
if result.ActionsFailed > 0 && rule.StopOnError {
break
}
}
}
status := types.RunStatusCompleted
if cancelled {
status = types.RunStatusCancelled
@@ -284,12 +332,24 @@ func buildConditionGroups(rule *models.Rule) []ldap.ConditionGroup {
return groups
}
func (e *Engine) planActions(rule *models.Rule, objects []MatchedObject) []PlannedAction {
func (e *Engine) planActions(rule *models.Rule, objects []MatchedObject, client membershipClient) []PlannedAction {
var planned []PlannedAction
for _, action := range rule.Actions {
matchedDNs := make([]string, 0, len(objects))
for _, o := range objects {
matchedDNs = append(matchedDNs, o.DN)
}
for i := range rule.Actions {
action := &rule.Actions[i]
if !action.IsEnabled {
continue
}
if isSetAction(action.ActionType) {
planned = append(planned, e.planSyncAction(action, rule, matchedDNs, client)...)
continue
}
for _, obj := range objects {
planned = append(planned, PlannedAction{
ActionID: action.ID,
@@ -303,6 +363,57 @@ func (e *Engine) planActions(rule *models.Rule, objects []MatchedObject) []Plann
return planned
}
// planSyncAction computes an accurate, non-mutating preview for a
// SyncGroupMembership action: a summary line plus one planned entry per member
// that would be added or removed.
func (e *Engine) planSyncAction(action *models.RuleAction, rule *models.Rule,
matchedDNs []string, client membershipClient) []PlannedAction {
plan := planMembership(action, rule, matchedDNs, client, e.managed, false)
if plan.Err != nil {
return []PlannedAction{{
ActionID: action.ID,
ActionType: string(types.ActionSyncGroupMembership),
TargetDN: plan.GroupDN,
Description: "cannot sync: " + plan.Err.Error(),
IsChange: false,
}}
}
var planned []PlannedAction
summary := fmt.Sprintf("sync %s: +%d add / -%d remove (%d already in sync)",
plan.GroupDN, len(plan.ToAdd), len(plan.ToRemove), plan.Unchanged)
if plan.GroupCreated {
summary = "create group + " + summary
}
planned = append(planned, PlannedAction{
ActionID: action.ID,
ActionType: string(types.ActionSyncGroupMembership),
TargetDN: plan.GroupDN,
Description: summary,
IsChange: plan.GroupCreated || len(plan.ToAdd) > 0 || len(plan.ToRemove) > 0,
})
for _, dn := range plan.ToAdd {
planned = append(planned, PlannedAction{
ActionID: action.ID,
ActionType: string(types.ActionAddToGroup),
TargetDN: dn,
Description: "add to " + plan.GroupDN,
IsChange: true,
})
}
for _, dn := range plan.ToRemove {
planned = append(planned, PlannedAction{
ActionID: action.ID,
ActionType: string(types.ActionRemoveFromGroupIfNoMatch),
TargetDN: dn,
Description: "remove from " + plan.GroupDN + " (no longer matches)",
IsChange: true,
})
}
return planned
}
func resolveBaseDN(rule *models.Rule, conn *models.ADConnection) string {
if rule.BaseDNOverride != nil && *rule.BaseDNOverride != "" {
return *rule.BaseDNOverride
@@ -342,6 +453,7 @@ func collectAttributes(rule *models.Rule) []string {
"mail": true,
"memberOf": true,
"member": true,
"canonicalName": true,
}
for _, g := range rule.ConditionGroups {
for _, c := range g.Conditions {
+233
View File
@@ -0,0 +1,233 @@
// Package engine - dynamic-group membership reconciliation.
//
// SyncGroupMembership is a set-level action: unlike per-object actions it runs
// once per target group with the entire matched set, diffs it against the
// group's current membership, and issues the adds/removes needed to make the
// group reflect the rule. This is the behaviour that turns a rule into an
// Adaxes/Active-Roles style dynamic group.
package engine
import (
"fmt"
"strings"
"github.com/Grace-Solutions/OrchestrAD/internal/directory/ldap"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
)
// membershipClient is the slice of the LDAP client that reconciliation needs.
// Declaring it as an interface keeps the reconciler unit-testable with a fake.
type membershipClient interface {
Exists(dn string) (bool, error)
GetGroupMembers(groupDN string) ([]string, error)
AddGroupMember(groupDN, memberDN string) error
RemoveGroupMember(groupDN, memberDN string) error
CreateGroup(groupDN, groupType, groupScope string) error
EnsureOUPath(ouDN string) error
}
// ManagedMemberStore records which memberships this rule added, so ManagedAdd
// mode can remove only what it added. Implementations are backed by the DB; a
// nil store disables managed-removal (it degrades to add-only removal).
type ManagedMemberStore interface {
List(ruleID, groupDN string) ([]string, error)
Add(ruleID, groupDN, memberDN string) error
Remove(ruleID, groupDN, memberDN string) error
}
// membershipPlan is the computed diff for one target group.
type membershipPlan struct {
GroupDN string
GroupCreated bool
ToAdd []string // original-cased member DNs to add
ToRemove []string // original-cased member DNs to remove
Unchanged int
Err error
}
// normDN returns the case/space-normalised key used to compare DNs. AD returns
// member DNs and search DNs in a consistent form, but comparing case-folded is
// safe and avoids spurious add/remove churn.
func normDN(dn string) string {
return strings.ToLower(strings.TrimSpace(dn))
}
// planMembership resolves the target group and computes the add/remove diff
// against the matched DN set without mutating anything. It is shared by preview
// (counts only) and execution (which then applies the plan).
func planMembership(action *models.RuleAction, rule *models.Rule, matchedDNs []string,
client membershipClient, store ManagedMemberStore, allowCreate bool) membershipPlan {
cfg, err := parseActionConfig(action.ConfigurationJSON)
if err != nil {
return membershipPlan{Err: fmt.Errorf("invalid action configuration: %w", err)}
}
groupDN := ldap.NormalizeGroupTarget(cfg.TargetGroupDN)
if groupDN == "" {
return membershipPlan{Err: fmt.Errorf("targetGroupDn is required for SyncGroupMembership")}
}
plan := membershipPlan{GroupDN: groupDN}
exists, err := client.Exists(groupDN)
if err != nil {
plan.Err = fmt.Errorf("checking group existence: %w", err)
return plan
}
if !exists {
if !cfg.CreateIfMissing {
plan.Err = fmt.Errorf("target group does not exist: %s", groupDN)
return plan
}
// Group would be created; its current membership is empty.
plan.GroupCreated = true
if allowCreate {
if err := client.EnsureOUPath(parentDN(groupDN)); err != nil {
plan.Err = fmt.Errorf("ensuring group OU path: %w", err)
return plan
}
if err := client.CreateGroup(groupDN, cfg.GroupType, cfg.GroupScope); err != nil {
plan.Err = fmt.Errorf("creating group: %w", err)
return plan
}
}
}
// Current members (empty when the group was, or would be, freshly created).
var current []string
if !plan.GroupCreated {
current, err = client.GetGroupMembers(groupDN)
if err != nil {
plan.Err = fmt.Errorf("reading group members: %w", err)
return plan
}
}
matchedByKey := make(map[string]string, len(matchedDNs))
for _, dn := range matchedDNs {
matchedByKey[normDN(dn)] = dn
}
currentByKey := make(map[string]string, len(current))
for _, dn := range current {
currentByKey[normDN(dn)] = dn
}
// Additions: matched objects not currently in the group.
for key, dn := range matchedByKey {
if _, ok := currentByKey[key]; ok {
plan.Unchanged++
} else {
plan.ToAdd = append(plan.ToAdd, dn)
}
}
// Removals depend on the sync mode.
mode := types.SyncMode(cfg.SyncMode)
if mode == "" {
mode = types.SyncModeFull
}
switch mode {
case types.SyncModeFull:
for key, dn := range currentByKey {
if _, ok := matchedByKey[key]; !ok {
plan.ToRemove = append(plan.ToRemove, dn)
}
}
case types.SyncModeManaged:
if store != nil {
managed, err := store.List(rule.ID, groupDN)
if err != nil {
plan.Err = fmt.Errorf("reading managed members: %w", err)
return plan
}
managedKeys := make(map[string]bool, len(managed))
for _, dn := range managed {
managedKeys[normDN(dn)] = true
}
for key, dn := range currentByKey {
if managedKeys[key] {
if _, ok := matchedByKey[key]; !ok {
plan.ToRemove = append(plan.ToRemove, dn)
}
}
}
}
case types.SyncModeAddOnly:
// never remove
}
return plan
}
// reconcileMembership applies a SyncGroupMembership action and returns one
// ActionResult per membership change (plus a group-created result when
// relevant). Adds are reported as AddToGroup and removes as
// RemoveFromGroupIfNoLongerMatched so the activity feed categorises them as
// syncs and removals.
func (e *Engine) reconcileMembership(action *models.RuleAction, rule *models.Rule,
matchedDNs []string, client membershipClient, store ManagedMemberStore) []ActionResult {
plan := planMembership(action, rule, matchedDNs, client, store, true)
if plan.Err != nil {
return []ActionResult{{
ActionID: action.ID,
ActionType: string(types.ActionSyncGroupMembership),
ObjectDN: plan.GroupDN,
Success: false,
Error: plan.Err.Error(),
Details: map[string]any{"targetGroupDn": plan.GroupDN},
}}
}
var results []ActionResult
if plan.GroupCreated {
results = append(results, ActionResult{
ActionID: action.ID,
ActionType: string(types.ActionEnsureGroupExists),
ObjectDN: plan.GroupDN,
Success: true,
Details: map[string]any{"groupDn": plan.GroupDN, "groupCreated": true},
})
}
for _, memberDN := range plan.ToAdd {
res := ActionResult{
ActionID: action.ID,
ActionType: string(types.ActionAddToGroup),
ObjectDN: memberDN,
Details: map[string]any{"groupDn": plan.GroupDN, "memberDn": memberDN, "reason": "matched"},
}
if err := client.AddGroupMember(plan.GroupDN, memberDN); err != nil {
res.Success = false
res.Error = err.Error()
} else {
res.Success = true
if store != nil {
_ = store.Add(rule.ID, plan.GroupDN, memberDN)
}
}
results = append(results, res)
}
for _, memberDN := range plan.ToRemove {
res := ActionResult{
ActionID: action.ID,
ActionType: string(types.ActionRemoveFromGroupIfNoMatch),
ObjectDN: memberDN,
Details: map[string]any{"groupDn": plan.GroupDN, "memberDn": memberDN, "reason": "noLongerMatched"},
}
if err := client.RemoveGroupMember(plan.GroupDN, memberDN); err != nil {
res.Success = false
res.Error = err.Error()
} else {
res.Success = true
if store != nil {
_ = store.Remove(rule.ID, plan.GroupDN, memberDN)
}
}
results = append(results, res)
}
return results
}
@@ -0,0 +1,197 @@
package engine
import (
"encoding/json"
"sort"
"testing"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
)
// fakeClient is an in-memory membershipClient for reconciliation tests.
type fakeClient struct {
members map[string][]string // groupDN -> member DNs
exists map[string]bool
created []string
}
func (f *fakeClient) Exists(dn string) (bool, error) { return f.exists[dn], nil }
func (f *fakeClient) GetGroupMembers(groupDN string) ([]string, error) {
return append([]string(nil), f.members[groupDN]...), nil
}
func (f *fakeClient) AddGroupMember(groupDN, memberDN string) error {
f.members[groupDN] = append(f.members[groupDN], memberDN)
return nil
}
func (f *fakeClient) RemoveGroupMember(groupDN, memberDN string) error {
cur := f.members[groupDN]
out := cur[:0]
for _, m := range cur {
if m != memberDN {
out = append(out, m)
}
}
f.members[groupDN] = out
return nil
}
func (f *fakeClient) CreateGroup(groupDN, groupType, groupScope string) error {
f.created = append(f.created, groupDN)
f.exists[groupDN] = true
f.members[groupDN] = nil
return nil
}
func (f *fakeClient) EnsureOUPath(ouDN string) error { return nil }
// fakeStore is an in-memory ManagedMemberStore.
type fakeStore struct {
m map[string]map[string]bool // ruleID|groupDN -> set of member DNs
}
func newFakeStore() *fakeStore { return &fakeStore{m: map[string]map[string]bool{}} }
func (s *fakeStore) key(ruleID, groupDN string) string { return ruleID + "|" + groupDN }
func (s *fakeStore) List(ruleID, groupDN string) ([]string, error) {
var out []string
for dn := range s.m[s.key(ruleID, groupDN)] {
out = append(out, dn)
}
return out, nil
}
func (s *fakeStore) Add(ruleID, groupDN, memberDN string) error {
k := s.key(ruleID, groupDN)
if s.m[k] == nil {
s.m[k] = map[string]bool{}
}
s.m[k][memberDN] = true
return nil
}
func (s *fakeStore) Remove(ruleID, groupDN, memberDN string) error {
delete(s.m[s.key(ruleID, groupDN)], memberDN)
return nil
}
func syncAction(t *testing.T, group, mode string, createIfMissing bool) *models.RuleAction {
t.Helper()
cfg := models.ActionConfig{TargetGroupDN: group, SyncMode: mode, CreateIfMissing: createIfMissing}
b, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal cfg: %v", err)
}
return &models.RuleAction{ID: "action-1", ActionType: string(types.ActionSyncGroupMembership), ConfigurationJSON: string(b), IsEnabled: true}
}
func sortedMembers(f *fakeClient, group string) []string {
out := append([]string(nil), f.members[group]...)
sort.Strings(out)
return out
}
const grp = "CN=Dyn,OU=Groups,DC=x,DC=y"
func TestReconcileFullSync(t *testing.T) {
eng := NewEngine(logging.Default())
f := &fakeClient{
members: map[string][]string{grp: {"CN=A,DC=x,DC=y", "CN=B,DC=x,DC=y", "CN=X,DC=x,DC=y"}},
exists: map[string]bool{grp: true},
}
rule := &models.Rule{ID: "rule-1"}
matched := []string{"CN=A,DC=x,DC=y", "CN=B,DC=x,DC=y", "CN=C,DC=x,DC=y"}
results := eng.reconcileMembership(syncAction(t, grp, string(types.SyncModeFull), false), rule, matched, f, newFakeStore())
// Expect: add C, remove X.
if got := sortedMembers(f, grp); len(got) != 3 || got[0] != "CN=A,DC=x,DC=y" || got[1] != "CN=B,DC=x,DC=y" || got[2] != "CN=C,DC=x,DC=y" {
t.Fatalf("full sync membership wrong: %v", got)
}
var adds, removes int
for _, r := range results {
if !r.Success {
t.Errorf("unexpected failure: %+v", r)
}
switch r.ActionType {
case string(types.ActionAddToGroup):
adds++
case string(types.ActionRemoveFromGroupIfNoMatch):
removes++
}
}
if adds != 1 || removes != 1 {
t.Errorf("expected 1 add / 1 remove, got %d / %d", adds, removes)
}
}
func TestReconcileManagedAddLeavesManualMembers(t *testing.T) {
eng := NewEngine(logging.Default())
f := &fakeClient{
members: map[string][]string{grp: {"CN=B,DC=x,DC=y", "CN=X,DC=x,DC=y", "CN=Manual,DC=x,DC=y"}},
exists: map[string]bool{grp: true},
}
store := newFakeStore()
// The rule previously added B and X.
_ = store.Add("rule-1", grp, "CN=B,DC=x,DC=y")
_ = store.Add("rule-1", grp, "CN=X,DC=x,DC=y")
rule := &models.Rule{ID: "rule-1"}
matched := []string{"CN=B,DC=x,DC=y"} // X no longer matches; Manual was never managed
eng.reconcileMembership(syncAction(t, grp, string(types.SyncModeManaged), false), rule, matched, f, store)
got := sortedMembers(f, grp)
// B stays (matched), X removed (managed + unmatched), Manual stays (not managed).
if len(got) != 2 || got[0] != "CN=B,DC=x,DC=y" || got[1] != "CN=Manual,DC=x,DC=y" {
t.Fatalf("managed sync membership wrong: %v", got)
}
}
func TestReconcileAddOnlyNeverRemoves(t *testing.T) {
eng := NewEngine(logging.Default())
f := &fakeClient{
members: map[string][]string{grp: {"CN=Stale,DC=x,DC=y"}},
exists: map[string]bool{grp: true},
}
rule := &models.Rule{ID: "rule-1"}
matched := []string{"CN=A,DC=x,DC=y"}
eng.reconcileMembership(syncAction(t, grp, string(types.SyncModeAddOnly), false), rule, matched, f, newFakeStore())
got := sortedMembers(f, grp)
if len(got) != 2 || got[0] != "CN=A,DC=x,DC=y" || got[1] != "CN=Stale,DC=x,DC=y" {
t.Fatalf("add-only membership wrong: %v", got)
}
}
func TestReconcileCreatesMissingGroup(t *testing.T) {
eng := NewEngine(logging.Default())
f := &fakeClient{members: map[string][]string{}, exists: map[string]bool{}}
rule := &models.Rule{ID: "rule-1"}
matched := []string{"CN=A,DC=x,DC=y"}
results := eng.reconcileMembership(syncAction(t, grp, string(types.SyncModeFull), true), rule, matched, f, newFakeStore())
if len(f.created) != 1 || f.created[0] != grp {
t.Fatalf("expected group to be created, created=%v", f.created)
}
if got := sortedMembers(f, grp); len(got) != 1 || got[0] != "CN=A,DC=x,DC=y" {
t.Fatalf("membership after create wrong: %v", got)
}
var sawCreate bool
for _, r := range results {
if r.ActionType == string(types.ActionEnsureGroupExists) {
sawCreate = true
}
}
if !sawCreate {
t.Errorf("expected an EnsureGroupExists result")
}
}
func TestReconcileMissingGroupNoCreateFails(t *testing.T) {
eng := NewEngine(logging.Default())
f := &fakeClient{members: map[string][]string{}, exists: map[string]bool{}}
rule := &models.Rule{ID: "rule-1"}
results := eng.reconcileMembership(syncAction(t, grp, string(types.SyncModeFull), false), rule, []string{"CN=A,DC=x,DC=y"}, f, newFakeStore())
if len(results) != 1 || results[0].Success {
t.Fatalf("expected a single failure result, got %+v", results)
}
}
+33 -1
View File
@@ -30,12 +30,14 @@ type Runner struct {
// New creates a new Runner
func New(db *sql.DB, connService *services.ConnectionService, logger *logging.Logger) *Runner {
eng := engine.NewEngine(logger)
eng.SetManagedStore(repository.NewManagedMemberRepository(db))
return &Runner{
ruleRepo: repository.NewRuleRepository(db),
connRepo: repository.NewConnectionRepository(db),
runRepo: repository.NewRuleRunRepository(db),
connService: connService,
engine: engine.NewEngine(logger),
engine: eng,
logger: logger,
}
}
@@ -123,6 +125,30 @@ func (r *Runner) PreviewRule(ctx context.Context, ruleID string) (*engine.Previe
return r.engine.Preview(ctx, rule, conn, client)
}
// PreviewRuleSpec previews an unsaved rule draft: it resolves the connection by
// ID, builds a client, and returns matched objects and planned actions without
// persisting the rule. Used by the editor's live preview.
func (r *Runner) PreviewRuleSpec(ctx context.Context, rule *models.Rule) (*engine.PreviewResult, error) {
if rule.ADConnectionID == "" {
return nil, fmt.Errorf("adConnectionId is required")
}
conn, err := r.connRepo.GetByID(rule.ADConnectionID)
if err != nil {
return nil, fmt.Errorf("loading AD connection: %w", err)
}
if conn == nil {
return nil, fmt.Errorf("AD connection not found: %s", rule.ADConnectionID)
}
client, err := r.connService.BuildClient(conn)
if err != nil {
return nil, fmt.Errorf("building LDAP client: %w", err)
}
defer client.Close()
return r.engine.Preview(ctx, rule, conn, client)
}
func (r *Runner) persistActionRecord(runID string, ar engine.ActionResult) {
status := "Succeeded"
if !ar.Success {
@@ -170,6 +196,9 @@ func (r *Runner) finalizeRun(run *models.RuleRun, result *engine.ExecutionResult
if err := r.runRepo.Update(run); err != nil {
r.logger.Warn("Runner", "Failed to update rule run %s: %v", run.ID, err)
}
if err := r.ruleRepo.UpdateLastRun(run.RuleID, completed, run.Status); err != nil {
r.logger.Warn("Runner", "Failed to update last-run on rule %s: %v", run.RuleID, err)
}
}
func (r *Runner) failRun(run *models.RuleRun, msg string) {
@@ -182,4 +211,7 @@ func (r *Runner) failRun(run *models.RuleRun, msg string) {
if err := r.runRepo.Update(run); err != nil {
r.logger.Warn("Runner", "Failed to update failed rule run %s: %v", run.ID, err)
}
if err := r.ruleRepo.UpdateLastRun(run.RuleID, now, string(types.RunStatusFailed)); err != nil {
r.logger.Warn("Runner", "Failed to update last-run on rule %s: %v", run.RuleID, err)
}
}
+28 -1
View File
@@ -11,9 +11,36 @@ import (
// entirely, which is the safe default for direct exposure.
type trustedProxies []*net.IPNet
// localProxyCIDRs are the loopback and private/link-local ranges trusted when
// the configuration uses the "local"/"private" keyword — the common case for
// an edge proxy running on the same host or LAN.
var localProxyCIDRs = []string{
"127.0.0.0/8", "::1/128",
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16", "fc00::/7", "fe80::/10",
}
func parseTrustedProxies(cidrs []string) (trustedProxies, error) {
var nets trustedProxies
// Expand keywords first: "local"/"private" -> local ranges; "all"/"*" ->
// everything; "none" -> nothing.
var expanded []string
for _, raw := range cidrs {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
continue
case "none", "off", "false":
return nil, nil
case "local", "private":
expanded = append(expanded, localProxyCIDRs...)
case "all", "any", "*":
expanded = append(expanded, "0.0.0.0/0", "::/0")
default:
expanded = append(expanded, raw)
}
}
var nets trustedProxies
for _, raw := range expanded {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
+51
View File
@@ -0,0 +1,51 @@
package server
import (
"net"
"testing"
)
func TestParseTrustedProxiesKeywords(t *testing.T) {
// "local" expands to loopback + private ranges.
tp, err := parseTrustedProxies([]string{"local"})
if err != nil {
t.Fatalf("local: %v", err)
}
for _, ip := range []string{"127.0.0.1", "10.1.2.3", "192.168.5.5", "172.16.0.1", "169.254.1.1"} {
if !tp.contains(net.ParseIP(ip)) {
t.Errorf("local should trust %s", ip)
}
}
if tp.contains(net.ParseIP("8.8.8.8")) {
t.Errorf("local should NOT trust a public IP")
}
// "all" trusts everything.
all, err := parseTrustedProxies([]string{"all"})
if err != nil {
t.Fatalf("all: %v", err)
}
if !all.contains(net.ParseIP("8.8.8.8")) {
t.Errorf("all should trust any IP")
}
// "none" and "" trust nothing.
for _, kw := range []string{"none", ""} {
none, err := parseTrustedProxies([]string{kw})
if err != nil {
t.Fatalf("%q: %v", kw, err)
}
if none.contains(net.ParseIP("127.0.0.1")) {
t.Errorf("%q should trust nothing", kw)
}
}
// An explicit CIDR still works and is additive with a keyword.
mix, err := parseTrustedProxies([]string{"local", "203.0.113.0/24"})
if err != nil {
t.Fatalf("mix: %v", err)
}
if !mix.contains(net.ParseIP("203.0.113.7")) || !mix.contains(net.ParseIP("10.0.0.1")) {
t.Errorf("mix should trust both the explicit CIDR and local ranges")
}
}
+69 -2
View File
@@ -17,6 +17,7 @@ import (
"github.com/Grace-Solutions/OrchestrAD/internal/rules/engine"
"github.com/Grace-Solutions/OrchestrAD/internal/rules/runner"
"github.com/Grace-Solutions/OrchestrAD/internal/services"
"github.com/Grace-Solutions/OrchestrAD/internal/tlsmgr"
"github.com/Grace-Solutions/OrchestrAD/internal/version"
"github.com/Grace-Solutions/OrchestrAD/internal/webui"
"github.com/go-chi/chi/v5"
@@ -36,6 +37,7 @@ type Dependencies struct {
BackupService *services.BackupService
SettingsService *services.SettingsService
DashboardService *services.DashboardService
ActivityService *services.ActivityService
ConfigService *services.ConfigService
AuditService *audit.Service
RuleRepo *repository.RuleRepository
@@ -43,6 +45,10 @@ type Dependencies struct {
ScheduleRepo *repository.ScheduleRepository
RunRepo *repository.RuleRunRepository
UserRepo *repository.UserRepository
// TLS, when non-nil, makes the server listen over HTTPS using the managed
// certificate. Nil serves plain HTTP (e.g. behind a TLS-terminating proxy).
TLS *tlsmgr.Manager
}
// Server represents the HTTP server
@@ -118,6 +124,13 @@ func (s *Server) setupRoutes() {
s.router.Get("/api/health", s.handleHealth)
s.router.Head("/api/health", s.handleHealth)
// API documentation: an OpenAPI 3 spec generated from this router (so it
// stays in sync) and a Swagger UI to browse and try it. Public so tooling
// can read the schema before authenticating.
openAPIHandler := api.NewOpenAPIHandler(s.router)
s.router.Get("/api/openapi.json", openAPIHandler.Spec)
s.router.Get("/api/docs", openAPIHandler.UI)
// API v1 routes
s.router.Route("/api/v1", func(r chi.Router) {
// Public system endpoints — discoverable without a session so that
@@ -130,12 +143,20 @@ func (s *Server) setupRoutes() {
// Auth endpoints. Login / logout / csrf are public by design; /me
// requires a valid session so clients can resolve the current user.
authHandler := api.NewAuthHandler(s.deps.AuthService, s.deps.AuditService, s.logger)
oidcHandler := api.NewOIDCHandler(s.deps.AuthService, s.deps.SettingsService, s.deps.AuditService, s.logger)
r.Route("/auth", func(r chi.Router) {
r.Post("/login", authHandler.Login)
r.Post("/logout", authHandler.Logout)
r.With(api.AuthMiddleware(s.deps.AuthService)).Get("/me", authHandler.Me)
r.With(api.AuthMiddleware(s.deps.AuthService)).Post("/change-password", authHandler.ChangePassword)
r.Get("/csrf", authHandler.CSRF)
// SSO (OIDC). status/login/callback are public; config is admin-only.
r.Get("/oidc/status", oidcHandler.Status)
r.Get("/oidc/login", oidcHandler.Login)
r.Get("/oidc/callback", oidcHandler.Callback)
r.With(api.AuthMiddleware(s.deps.AuthService)).Get("/oidc/config", oidcHandler.GetConfig)
r.With(api.AuthMiddleware(s.deps.AuthService)).Put("/oidc/config", oidcHandler.PutConfig)
})
// Everything below this group requires a valid session token. API
@@ -177,6 +198,9 @@ func (s *Server) setupRoutes() {
r.Delete("/{id}", connectionsHandler.Delete)
r.Post("/{id}/test", connectionsHandler.Test)
r.Post("/{id}/query-preview", connectionsHandler.QueryPreview)
r.Get("/{id}/directory", connectionsHandler.DirectorySearch)
r.Get("/{id}/attributes", connectionsHandler.Attributes)
r.Get("/{id}/attribute-values", connectionsHandler.AttributeValues)
r.Post("/{id}/enable", connectionsHandler.Enable)
r.Post("/{id}/disable", connectionsHandler.Disable)
})
@@ -199,6 +223,8 @@ func (s *Server) setupRoutes() {
r.Route("/rules", func(r chi.Router) {
r.Get("/", rulesHandler.List)
r.Post("/", rulesHandler.Create)
r.Get("/metadata", rulesHandler.Metadata)
r.Post("/preview", rulesHandler.PreviewSpec)
r.Get("/{id}", rulesHandler.Get)
r.Put("/{id}", rulesHandler.Update)
r.Delete("/{id}", rulesHandler.Delete)
@@ -256,12 +282,29 @@ func (s *Server) setupRoutes() {
r.Get("/summary", dashboardHandler.Summary)
})
// Activity intelligence (action ledger roll-ups + drill-in feed)
activityHandler := api.NewActivityHandler(s.deps.ActivityService, s.logger)
r.Route("/activity", func(r chi.Router) {
r.Get("/", activityHandler.List)
r.Get("/summary", activityHandler.Summary)
})
// Configuration export / import
configHandler := api.NewConfigHandler(s.deps.ConfigService, s.deps.AuditService, s.logger)
r.Route("/config", func(r chi.Router) {
r.Get("/export", configHandler.Export)
r.Post("/import", configHandler.Import)
})
// TLS certificate configuration (mode, bring-your-own upload,
// Windows-store selection).
tlsHandler := api.NewTLSHandler(s.deps.SettingsService, s.deps.TLS, s.deps.AuditService, s.logger)
r.Route("/tls", func(r chi.Router) {
r.Get("/status", tlsHandler.Status)
r.Post("/mode", tlsHandler.SetMode)
r.Post("/certificate", tlsHandler.UploadCertificate)
r.Get("/windows-store", tlsHandler.WindowsCerts)
})
}) // end protected group
})
@@ -296,6 +339,7 @@ func (s *Server) handleNotImplemented(w http.ResponseWriter, r *http.Request) {
// Run starts the HTTP server and blocks until shutdown
func (s *Server) Run(ctx context.Context) error {
addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port)
useTLS := s.deps.TLS != nil
s.httpSrv = &http.Server{
Addr: addr,
Handler: s.router,
@@ -303,12 +347,35 @@ func (s *Server) Run(ctx context.Context) error {
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
if useTLS {
s.httpSrv.TLSConfig = s.deps.TLS.TLSConfig()
}
s.logger.Info("Server", "Starting HTTP server on %s", addr)
scheme, schemeLower := "HTTP", "http"
if useTLS {
scheme, schemeLower = "HTTPS", "https"
}
s.logger.Info("Server", "Starting %s server on %s", scheme, addr)
// Log a clickable URL. 0.0.0.0/:: are not directly reachable, so show
// localhost for those.
accessHost := s.config.Server.Host
if accessHost == "0.0.0.0" || accessHost == "::" || accessHost == "" {
accessHost = "localhost"
}
s.logger.Info("Server", "OrchestrAD is available at %s://%s:%d/", schemeLower, accessHost, s.config.Server.Port)
errChan := make(chan error, 1)
go func() {
if err := s.httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
var err error
if useTLS {
// Certificates come from the manager's GetCertificate, so the file
// arguments are intentionally empty.
err = s.httpSrv.ListenAndServeTLS("", "")
} else {
err = s.httpSrv.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
errChan <- err
}
}()
@@ -0,0 +1,350 @@
// Package services - activity intelligence aggregation service.
//
// The activity service turns the raw rule_run_actions ledger into operator-
// friendly intelligence: how many syncs, operations and removals happened, to
// how many objects, and a drill-in feed answering what action ran, what
// happened, to which object, triggered by whom, and when.
package services
import (
"database/sql"
"strings"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
)
// Activity categories group the individual AD action types into the three
// buckets operators reason about.
const (
CategorySync = "Sync" // membership additions (AddToGroup / AddGroupToGroup)
CategoryOperation = "Operation" // structural changes (MoveToOu / EnsureGroupExists)
CategoryRemoval = "Removal" // membership removals (RemoveFromGroupIfNoLongerMatched)
)
// syncActionTypes / removalActionTypes drive both the SQL categorisation and
// the Go-side helpers so the two never drift apart.
var (
syncActionTypes = []string{"AddToGroup", "AddGroupToGroup"}
removalActionTypes = []string{"RemoveFromGroupIfNoLongerMatched"}
)
// categorize maps an action type to its activity category.
func categorize(actionType string) string {
for _, a := range syncActionTypes {
if a == actionType {
return CategorySync
}
}
for _, a := range removalActionTypes {
if a == actionType {
return CategoryRemoval
}
}
return CategoryOperation
}
// ActivityWindow summarises action activity over a time window.
type ActivityWindow struct {
Key string `json:"key"`
Label string `json:"label"`
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
Syncs int `json:"syncs"`
Operations int `json:"operations"`
Removals int `json:"removals"`
ObjectsAffected int `json:"objectsAffected"`
}
// ActionTypeCount is an all-time roll-up for a single action type.
type ActionTypeCount struct {
ActionType string `json:"actionType"`
Category string `json:"category"`
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
}
// RuleActivityCount ranks rules by how much activity they generated.
type RuleActivityCount struct {
RuleID string `json:"ruleId"`
RuleName string `json:"ruleName"`
Total int `json:"total"`
Failed int `json:"failed"`
}
// ActivitySummary is the composite response for the intelligence page header.
type ActivitySummary struct {
Windows []ActivityWindow `json:"windows"`
ByActionType []ActionTypeCount `json:"byActionType"`
TopRules []RuleActivityCount `json:"topRules"`
GeneratedUTC string `json:"generatedUtc"`
}
// ActivityRecord is one row of the drill-in feed.
type ActivityRecord struct {
ID string `json:"id"`
RuleRunID string `json:"ruleRunId"`
RuleID string `json:"ruleId"`
RuleName string `json:"ruleName"`
ObjectDN string `json:"objectDn"`
ActionType string `json:"actionType"`
Category string `json:"category"`
Status string `json:"status"`
DetailsJSON *string `json:"detailsJson,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
DurationMS *int `json:"durationMs,omitempty"`
TriggeredBy *string `json:"triggeredBy,omitempty"`
CreatedUTC string `json:"createdUtc"`
}
// ActivityFilter narrows the drill-in feed.
type ActivityFilter struct {
Category string
ActionType string
Status string
RuleID string
Search string
Offset int
Limit int
}
// ActivityService aggregates read-only intelligence from the action ledger.
type ActivityService struct {
db *sql.DB
logger *logging.Logger
}
// NewActivityService creates a new ActivityService.
func NewActivityService(db *sql.DB, logger *logging.Logger) *ActivityService {
return &ActivityService{db: db, logger: logger}
}
// caseSum builds a "COALESCE(SUM(CASE WHEN <cond> THEN 1 ELSE 0 END),0)" fragment.
func caseSum(cond string) string {
return "COALESCE(SUM(CASE WHEN " + cond + " THEN 1 ELSE 0 END),0)"
}
// inList renders a quoted SQL IN(...) list from action-type constants. The
// values are compile-time constants, never user input, so inlining is safe.
func inList(values []string) string {
quoted := make([]string, len(values))
for i, v := range values {
quoted[i] = "'" + v + "'"
}
return "(" + strings.Join(quoted, ",") + ")"
}
// Summary returns the composite intelligence payload.
func (s *ActivityService) Summary() (*ActivitySummary, error) {
now := time.Now().UTC()
out := &ActivitySummary{GeneratedUTC: now.Format(time.RFC3339)}
windows := []struct {
key, label string
since *time.Time
}{
{"24h", "Last 24 hours", ptrTime(now.Add(-24 * time.Hour))},
{"7d", "Last 7 days", ptrTime(now.Add(-7 * 24 * time.Hour))},
{"all", "All time", nil},
}
for _, w := range windows {
win, err := s.loadWindow(w.key, w.label, w.since)
if err != nil {
return nil, err
}
out.Windows = append(out.Windows, win)
}
byType, err := s.loadByActionType()
if err != nil {
return nil, err
}
out.ByActionType = byType
topRules, err := s.loadTopRules(8)
if err != nil {
return nil, err
}
out.TopRules = topRules
return out, nil
}
func (s *ActivityService) loadWindow(key, label string, since *time.Time) (ActivityWindow, error) {
win := ActivityWindow{Key: key, Label: label}
query := `
SELECT COUNT(*),
` + caseSum("ra.status = 'Succeeded'") + `,
` + caseSum("ra.status = 'Failed'") + `,
` + caseSum("ra.action_type IN "+inList(syncActionTypes)) + `,
` + caseSum("ra.action_type IN "+inList(removalActionTypes)) + `,
COUNT(DISTINCT ra.object_dn)
FROM rule_run_actions ra`
var args []any
if since != nil {
query += ` WHERE ra.created_utc >= ?`
args = append(args, since.Format(time.RFC3339))
}
var syncs, removals int
if err := s.db.QueryRow(query, args...).Scan(
&win.Total, &win.Success, &win.Failed, &syncs, &removals, &win.ObjectsAffected,
); err != nil {
return win, err
}
win.Syncs = syncs
win.Removals = removals
win.Operations = win.Total - syncs - removals
return win, nil
}
func (s *ActivityService) loadByActionType() ([]ActionTypeCount, error) {
rows, err := s.db.Query(`
SELECT ra.action_type, COUNT(*),
` + caseSum("ra.status = 'Succeeded'") + `,
` + caseSum("ra.status = 'Failed'") + `
FROM rule_run_actions ra
GROUP BY ra.action_type
ORDER BY COUNT(*) DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]ActionTypeCount, 0)
for rows.Next() {
var c ActionTypeCount
if err := rows.Scan(&c.ActionType, &c.Total, &c.Success, &c.Failed); err != nil {
return nil, err
}
c.Category = categorize(c.ActionType)
out = append(out, c)
}
return out, rows.Err()
}
func (s *ActivityService) loadTopRules(limit int) ([]RuleActivityCount, error) {
rows, err := s.db.Query(`
SELECT r.id, r.name, COUNT(*),
`+caseSum("ra.status = 'Failed'")+`
FROM rule_run_actions ra
JOIN rule_runs rr ON rr.id = ra.rule_run_id
JOIN rules r ON r.id = rr.rule_id
GROUP BY r.id, r.name
ORDER BY COUNT(*) DESC
LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]RuleActivityCount, 0)
for rows.Next() {
var c RuleActivityCount
if err := rows.Scan(&c.RuleID, &c.RuleName, &c.Total, &c.Failed); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// ListActions returns a filtered, paginated slice of the action feed plus the
// total matching count.
func (s *ActivityService) ListActions(f ActivityFilter) ([]ActivityRecord, int, error) {
where := []string{"1=1"}
var args []any
switch f.Category {
case CategorySync:
where = append(where, "ra.action_type IN "+inList(syncActionTypes))
case CategoryRemoval:
where = append(where, "ra.action_type IN "+inList(removalActionTypes))
case CategoryOperation:
where = append(where, "ra.action_type NOT IN "+inList(append(append([]string{}, syncActionTypes...), removalActionTypes...)))
}
if f.ActionType != "" {
where = append(where, "ra.action_type = ?")
args = append(args, f.ActionType)
}
if f.Status != "" {
where = append(where, "ra.status = ?")
args = append(args, f.Status)
}
if f.RuleID != "" {
where = append(where, "rr.rule_id = ?")
args = append(args, f.RuleID)
}
if f.Search != "" {
where = append(where, "ra.object_dn LIKE ?")
args = append(args, "%"+f.Search+"%")
}
clause := strings.Join(where, " AND ")
var total int
countQuery := `
SELECT COUNT(*)
FROM rule_run_actions ra
JOIN rule_runs rr ON rr.id = ra.rule_run_id
JOIN rules r ON r.id = rr.rule_id
WHERE ` + clause
if err := s.db.QueryRow(countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 {
limit = 50
}
listArgs := append(append([]any{}, args...), limit, f.Offset)
rows, err := s.db.Query(`
SELECT ra.id, ra.rule_run_id, rr.rule_id, r.name, ra.object_dn,
ra.action_type, ra.status, ra.details_json, ra.error_message,
ra.duration_ms, rr.triggered_by, ra.created_utc
FROM rule_run_actions ra
JOIN rule_runs rr ON rr.id = ra.rule_run_id
JOIN rules r ON r.id = rr.rule_id
WHERE `+clause+`
ORDER BY ra.created_utc DESC
LIMIT ? OFFSET ?`, listArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]ActivityRecord, 0, limit)
for rows.Next() {
var rec ActivityRecord
var details, errMsg, triggeredBy sql.NullString
var duration sql.NullInt64
if err := rows.Scan(
&rec.ID, &rec.RuleRunID, &rec.RuleID, &rec.RuleName, &rec.ObjectDN,
&rec.ActionType, &rec.Status, &details, &errMsg,
&duration, &triggeredBy, &rec.CreatedUTC,
); err != nil {
return nil, 0, err
}
rec.Category = categorize(rec.ActionType)
if details.Valid {
v := details.String
rec.DetailsJSON = &v
}
if errMsg.Valid {
v := errMsg.String
rec.ErrorMessage = &v
}
if duration.Valid {
v := int(duration.Int64)
rec.DurationMS = &v
}
if triggeredBy.Valid {
v := triggeredBy.String
rec.TriggeredBy = &v
}
out = append(out, rec)
}
return out, total, rows.Err()
}
func ptrTime(t time.Time) *time.Time { return &t }
@@ -0,0 +1,131 @@
package services
import (
"path/filepath"
"testing"
"github.com/Grace-Solutions/OrchestrAD/internal/config"
"github.com/Grace-Solutions/OrchestrAD/internal/db"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
)
func TestActivityServiceSummaryAndFeed(t *testing.T) {
database, err := db.New(config.DatabaseConfig{
Path: filepath.Join(t.TempDir(), "activity_test.db"),
MaxOpenConns: 1,
MaxIdleConns: 1,
WALMode: true,
ForeignKeys: true,
}, logging.Default())
if err != nil {
t.Fatalf("db.New: %v", err)
}
defer database.Close()
if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
conn := database.Conn()
// A connection satisfies the rules.ad_connection_id FK.
connRepo := repository.NewConnectionRepository(conn)
adc := &models.ADConnection{
Name: "act-conn", Hosts: "dc1", Port: 389, RootDN: "DC=y",
DefaultSearchScope: "Subtree", TimeoutSeconds: 15, PageSize: 1000, IsEnabled: true,
}
if err := connRepo.Create(adc); err != nil {
t.Fatalf("create connection: %v", err)
}
ruleRepo := repository.NewRuleRepository(conn)
rule := &models.Rule{Name: "act-rule", ObjectType: "User", ADConnectionID: adc.ID, ExecutionMode: "Automatic", GroupJoinOperator: "AND"}
if err := ruleRepo.Create(rule); err != nil {
t.Fatalf("create rule: %v", err)
}
// A rule_action satisfies the rule_run_actions.rule_action_id FK.
actionID := "act-action-1"
if _, err := conn.Exec(`
INSERT INTO rule_actions (id, rule_id, action_type, is_enabled, sort_order, configuration_json, rollback_mode, created_utc, updated_utc)
VALUES (?, ?, 'AddToGroup', 1, 0, '{}', 'None', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
actionID, rule.ID); err != nil {
t.Fatalf("insert rule_action: %v", err)
}
runRepo := repository.NewRuleRunRepository(conn)
who := "tester"
run := &models.RuleRun{RuleID: rule.ID, Status: "Success", ExecutionMode: "Automatic", TriggeredBy: &who}
if err := runRepo.Create(run); err != nil {
t.Fatalf("create run: %v", err)
}
// Two syncs (one failed), one operation, one removal.
actions := []struct {
actionType, status, dn string
}{
{"AddToGroup", "Succeeded", "CN=Alice,OU=x,DC=y"},
{"AddToGroup", "Failed", "CN=Bob,OU=x,DC=y"},
{"MoveToOu", "Succeeded", "CN=Alice,OU=x,DC=y"},
{"RemoveFromGroupIfNoLongerMatched", "Succeeded", "CN=Carol,OU=x,DC=y"},
}
for _, a := range actions {
if err := runRepo.CreateAction(&models.RuleRunAction{
RuleRunID: run.ID, RuleActionID: actionID, ObjectDN: a.dn, ActionType: a.actionType, Status: a.status,
}); err != nil {
t.Fatalf("create action: %v", err)
}
}
svc := NewActivityService(conn, logging.Default())
sum, err := svc.Summary()
if err != nil {
t.Fatalf("summary: %v", err)
}
all := findWindow(t, sum.Windows, "all")
if all.Total != 4 || all.Syncs != 2 || all.Operations != 1 || all.Removals != 1 {
t.Errorf("window totals wrong: %+v", all)
}
if all.Failed != 1 || all.Success != 3 {
t.Errorf("window success/failed wrong: %+v", all)
}
if all.ObjectsAffected != 3 { // Alice, Bob, Carol
t.Errorf("objectsAffected = %d, want 3", all.ObjectsAffected)
}
if len(sum.TopRules) != 1 || sum.TopRules[0].Total != 4 || sum.TopRules[0].Failed != 1 {
t.Errorf("topRules wrong: %+v", sum.TopRules)
}
// Removal category filter should return exactly the one removal action.
recs, total, err := svc.ListActions(ActivityFilter{Category: CategoryRemoval, Limit: 50})
if err != nil {
t.Fatalf("list removals: %v", err)
}
if total != 1 || len(recs) != 1 || recs[0].ActionType != "RemoveFromGroupIfNoLongerMatched" {
t.Errorf("removal filter wrong: total=%d recs=%+v", total, recs)
}
if recs[0].TriggeredBy == nil || *recs[0].TriggeredBy != who {
t.Errorf("triggeredBy not threaded through: %+v", recs[0].TriggeredBy)
}
// Failed status filter should surface the single failed sync.
failed, ftotal, err := svc.ListActions(ActivityFilter{Status: "Failed", Limit: 50})
if err != nil {
t.Fatalf("list failed: %v", err)
}
if ftotal != 1 || len(failed) != 1 || failed[0].Category != CategorySync {
t.Errorf("failed filter wrong: total=%d recs=%+v", ftotal, failed)
}
}
func findWindow(t *testing.T, windows []ActivityWindow, key string) ActivityWindow {
t.Helper()
for _, w := range windows {
if w.Key == key {
return w
}
}
t.Fatalf("window %q not found", key)
return ActivityWindow{}
}
+24 -6
View File
@@ -25,12 +25,27 @@ func NewAPIKeyService(db *sql.DB, logger *logging.Logger) *APIKeyService {
}
}
// API key scopes.
const (
APIKeyScopeRead = "read" // GET/HEAD only
APIKeyScopeReadWrite = "readwrite" // full access
)
// NormalizeAPIKeyScope returns a valid scope, defaulting unknown/empty to readwrite.
func NormalizeAPIKeyScope(s string) string {
if s == APIKeyScopeRead {
return APIKeyScopeRead
}
return APIKeyScopeReadWrite
}
// APIKey represents an API key (public view)
type APIKey struct {
ID string
UserID string
Name string
KeyPrefix string
Scope string
ExpiresAt *time.Time
IsEnabled bool
LastUsedAt *time.Time
@@ -48,6 +63,7 @@ type CreateAPIKeyResult struct {
type CreateAPIKeyInput struct {
UserID string
Name string
Scope string
ExpiresAt *time.Time
}
@@ -68,21 +84,22 @@ func (s *APIKeyService) Create(input CreateAPIKeyInput) (*CreateAPIKeyResult, er
id := uuid.New().String()
now := time.Now().UTC()
scope := NormalizeAPIKeyScope(input.Scope)
_, err = s.db.Exec(`
INSERT INTO api_keys (
id, user_id, name, key_prefix, key_hash,
expires_utc, is_enabled, created_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
expires_utc, is_enabled, scope, created_utc
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
id, input.UserID, input.Name, prefix, keyHash,
formatTimePtr(input.ExpiresAt), 1, formatTime(now),
formatTimePtr(input.ExpiresAt), 1, scope, formatTime(now),
)
if err != nil {
return nil, fmt.Errorf("failed to create API key: %w", err)
}
s.logger.Info("APIKeyService", "Created API key '%s' for user %s", input.Name, input.UserID)
s.logger.Info("APIKeyService", "Created API key '%s' (%s) for user %s", input.Name, scope, input.UserID)
return &CreateAPIKeyResult{
APIKey: APIKey{
@@ -90,6 +107,7 @@ func (s *APIKeyService) Create(input CreateAPIKeyInput) (*CreateAPIKeyResult, er
UserID: input.UserID,
Name: input.Name,
KeyPrefix: prefix,
Scope: scope,
ExpiresAt: input.ExpiresAt,
IsEnabled: true,
CreatedAt: now,
@@ -186,7 +204,7 @@ func (s *APIKeyService) SetEnabled(id string, enabled bool) error {
func (s *APIKeyService) List(userID string) ([]APIKey, error) {
rows, err := s.db.Query(`
SELECT id, user_id, name, key_prefix, expires_utc,
is_enabled, last_used_utc, created_utc, revoked_utc
is_enabled, scope, last_used_utc, created_utc, revoked_utc
FROM api_keys WHERE user_id = ?
ORDER BY created_utc DESC
`, userID)
@@ -202,7 +220,7 @@ func (s *APIKeyService) List(userID string) ([]APIKey, error) {
var expires, lastUsed, revoked sql.NullString
if err := rows.Scan(
&k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &expires,
&isEnabled, &lastUsed, &k.CreatedAt, &revoked,
&isEnabled, &k.Scope, &lastUsed, &k.CreatedAt, &revoked,
); err != nil {
return nil, err
}
@@ -4,7 +4,10 @@ package services
import (
"database/sql"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/crypto"
@@ -12,6 +15,7 @@ import (
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
"github.com/Grace-Solutions/OrchestrAD/internal/types"
goldap "github.com/go-ldap/ldap/v3"
)
@@ -21,6 +25,15 @@ type ConnectionService struct {
credRepo *repository.CredentialRepository
encryptor *crypto.Encryptor
logger *logging.Logger
// schemaCache memoises the per-(connection, objectType) applicable attribute
// name set so the class-hierarchy walk is not repeated on every keystroke.
schemaCache sync.Map // key "connID|objectType" -> *attrSetCache
}
type attrSetCache struct {
names []string
expires time.Time
}
// NewConnectionService creates a new ConnectionService
@@ -261,6 +274,306 @@ func (s *ConnectionService) QueryPreview(conn *models.ADConnection, input QueryP
return result, nil
}
// DirectoryObject is a lightweight directory entry returned by SearchDirectory
// for operator-facing pickers (target groups, base/target OUs).
type DirectoryObject struct {
DN string `json:"dn"`
Name string `json:"name"`
CanonicalName string `json:"canonicalName"`
ObjectType string `json:"objectType"`
}
// SearchDirectory finds groups or organizational units (and users/computers)
// matching an optional substring, for use by the rule editor's pickers.
// objectType is one of "Group", "OU", "User", "Computer".
func (s *ConnectionService) SearchDirectory(conn *models.ADConnection, objectType, q string, limit int) ([]DirectoryObject, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
var classFilter, nameAttr string
switch objectType {
case "OU", "OrganizationalUnit":
classFilter, nameAttr = "(objectClass=organizationalUnit)", "ou"
case "User":
classFilter, nameAttr = "(&(objectCategory=person)(objectClass=user))", "cn"
case "Computer":
classFilter, nameAttr = "(objectClass=computer)", "cn"
default: // Group
objectType, classFilter, nameAttr = "Group", "(objectClass=group)", "cn"
}
filter := classFilter
if q = strings.TrimSpace(q); q != "" {
esc := ldap.EscapeFilterValue(q)
if nameAttr == "ou" {
filter = fmt.Sprintf("(&%s(ou=*%s*))", classFilter, esc)
} else {
filter = fmt.Sprintf("(&%s(|(cn=*%s*)(sAMAccountName=*%s*)))", classFilter, esc, esc)
}
}
client, err := s.BuildClient(conn)
if err != nil {
return nil, err
}
defer client.Close()
entries, err := client.Search(conn.RootDN, goldap.ScopeWholeSubtree, filter,
[]string{"cn", "ou", "distinguishedName", "canonicalName"})
if err != nil {
return nil, err
}
out := make([]DirectoryObject, 0, limit)
for _, entry := range entries {
if len(out) >= limit {
break
}
name := entry.GetAttributeValue(nameAttr)
if name == "" {
name = entry.GetAttributeValue("cn")
}
out = append(out, DirectoryObject{
DN: entry.DN,
Name: name,
CanonicalName: ldap.CanonicalName(entry.DN, entry.GetAttributeValue("canonicalName")),
ObjectType: objectType,
})
}
return out, nil
}
// AttributeInfo is a schema attribute offered to the filter builder.
type AttributeInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// attrNameRe restricts attribute names to the LDAP descriptor charset, so a
// name can be placed into a filter without escaping / injection risk.
var attrNameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*$`)
// classForObjectType maps an OrchestrAD object type to its AD classSchema
// lDAPDisplayName, the entry point for the attribute-applicability walk.
func classForObjectType(objectType string) string {
switch objectType {
case "Computer":
return "computer"
case "Group":
return "group"
default:
return "user"
}
}
// SchemaAttributes returns the schema attributes that apply to objectType
// (User/Computer/Group), optionally narrowed to those whose name contains q.
// The applicable-attribute set is derived from the class hierarchy and cached.
func (s *ConnectionService) SchemaAttributes(conn *models.ADConnection, objectType, q string, limit int) ([]AttributeInfo, error) {
q = strings.TrimSpace(q)
if limit <= 0 || limit > 500 {
limit = 50
}
client, err := s.BuildClient(conn)
if err != nil {
return nil, err
}
defer client.Close()
schemaNC, err := s.schemaNamingContext(client)
if err != nil {
return nil, err
}
names, err := s.applicableAttributeNames(client, conn.ID, schemaNC, objectType)
if err != nil {
return nil, err
}
// Filter by substring, cap.
ql := strings.ToLower(q)
matched := make([]string, 0, limit)
for _, n := range names {
if ql == "" || strings.Contains(strings.ToLower(n), ql) {
matched = append(matched, n)
if len(matched) >= limit {
break
}
}
}
if len(matched) == 0 {
return []AttributeInfo{}, nil
}
// Fetch descriptions for the matched subset in a single OR-filter search.
descByName := s.attributeDescriptions(client, schemaNC, matched)
out := make([]AttributeInfo, 0, len(matched))
for _, n := range matched {
out = append(out, AttributeInfo{Name: n, Description: descByName[strings.ToLower(n)]})
}
return out, nil
}
func (s *ConnectionService) schemaNamingContext(client *ldap.Client) (string, error) {
root, err := client.SearchOne("", goldap.ScopeBaseObject, "(objectClass=*)", []string{"schemaNamingContext"})
if err != nil {
return "", fmt.Errorf("reading RootDSE: %w", err)
}
if root == nil {
return "", fmt.Errorf("RootDSE not available")
}
nc := root.GetAttributeValue("schemaNamingContext")
if nc == "" {
return "", fmt.Errorf("directory does not expose a schema naming context")
}
return nc, nil
}
// applicableAttributeNames returns the sorted set of attribute lDAPDisplayNames
// that may be set on objectType, by walking the classSchema hierarchy
// (subClassOf up to top, plus auxiliary classes) and unioning each class's
// may/must-contain attributes. Cached per connection+objectType for 10 minutes.
func (s *ConnectionService) applicableAttributeNames(client *ldap.Client, connID, schemaNC, objectType string) ([]string, error) {
cacheKey := connID + "|" + objectType
if v, ok := s.schemaCache.Load(cacheKey); ok {
if c := v.(*attrSetCache); time.Now().Before(c.expires) {
return c.names, nil
}
}
attrSet := map[string]bool{}
visited := map[string]bool{}
queue := []string{classForObjectType(objectType)}
for len(queue) > 0 {
cls := queue[0]
queue = queue[1:]
if cls == "" || visited[cls] {
continue
}
visited[cls] = true
entry, err := client.SearchOne(schemaNC, goldap.ScopeSingleLevel,
fmt.Sprintf("(&(objectClass=classSchema)(lDAPDisplayName=%s))", ldap.EscapeFilterValue(cls)),
[]string{"mayContain", "systemMayContain", "mustContain", "systemMustContain",
"subClassOf", "auxiliaryClass", "systemAuxiliaryClass"})
if err != nil {
return nil, err
}
if entry == nil {
continue
}
for _, a := range []string{"mayContain", "systemMayContain", "mustContain", "systemMustContain"} {
for _, v := range entry.GetAttributeValues(a) {
attrSet[v] = true
}
}
for _, a := range []string{"subClassOf", "auxiliaryClass", "systemAuxiliaryClass"} {
for _, v := range entry.GetAttributeValues(a) {
if v != "" && v != cls {
queue = append(queue, v)
}
}
}
}
names := make([]string, 0, len(attrSet))
for n := range attrSet {
names = append(names, n)
}
sort.Slice(names, func(i, j int) bool { return strings.ToLower(names[i]) < strings.ToLower(names[j]) })
s.schemaCache.Store(cacheKey, &attrSetCache{names: names, expires: time.Now().Add(10 * time.Minute)})
return names, nil
}
// attributeDescriptions fetches adminDescription for a set of attribute names
// in one search, returning a lowercase-name-keyed map.
func (s *ConnectionService) attributeDescriptions(client *ldap.Client, schemaNC string, names []string) map[string]string {
if len(names) == 0 {
return map[string]string{}
}
var b strings.Builder
b.WriteString("(&(objectClass=attributeSchema)(|")
for _, n := range names {
b.WriteString("(lDAPDisplayName=")
b.WriteString(ldap.EscapeFilterValue(n))
b.WriteString(")")
}
b.WriteString("))")
out := map[string]string{}
entries, err := client.SearchWithLimit(schemaNC, goldap.ScopeSingleLevel, b.String(),
[]string{"lDAPDisplayName", "adminDescription"}, len(names))
if err != nil {
return out // descriptions are best-effort
}
for _, e := range entries {
name := e.GetAttributeValue("lDAPDisplayName")
if name != "" {
out[strings.ToLower(name)] = e.GetAttributeValue("adminDescription")
}
}
return out
}
// DistinctAttributeValues samples objects of objectType and returns the
// distinct values present for a single attribute, optionally narrowed by a
// substring q and a base DN. Bounded by a scan cap so it stays cheap on large
// directories.
func (s *ConnectionService) DistinctAttributeValues(conn *models.ADConnection, objectType, attribute, q, baseDN string, limit int) ([]string, error) {
attribute = strings.TrimSpace(attribute)
if !attrNameRe.MatchString(attribute) {
return nil, fmt.Errorf("invalid attribute name")
}
if limit <= 0 || limit > 200 {
limit = 50
}
if baseDN == "" {
baseDN = conn.RootDN
}
filter := fmt.Sprintf("(&%s(%s=*))", ldap.ObjectFilter(types.ObjectType(objectType)), attribute)
if q = strings.TrimSpace(q); q != "" {
filter = fmt.Sprintf("(&%s(%s=*%s*))", ldap.ObjectFilter(types.ObjectType(objectType)), attribute, ldap.EscapeFilterValue(q))
}
client, err := s.BuildClient(conn)
if err != nil {
return nil, err
}
defer client.Close()
// Scan up to ~2000 objects; that is plenty to surface the common values
// without walking an entire large directory.
entries, err := client.SearchWithLimit(baseDN, goldap.ScopeWholeSubtree, filter, []string{attribute}, 2000)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
out := make([]string, 0, limit)
for _, e := range entries {
for _, v := range e.GetAttributeValues(attribute) {
key := strings.ToLower(v)
if v == "" || seen[key] {
continue
}
seen[key] = true
out = append(out, v)
if len(out) >= limit {
break
}
}
if len(out) >= limit {
break
}
}
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i]) < strings.ToLower(out[j]) })
return out, nil
}
func mapScope(scope string) int {
switch scope {
case "base", "Base", "BaseObject":
@@ -0,0 +1,91 @@
// Package services - database maintenance (history retention + compaction)
package services
import (
"context"
"database/sql"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/config"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
)
// MaintenanceService prunes aged history and compacts the database so it does
// not grow without bound. It runs once at startup and then on a fixed interval.
type MaintenanceService struct {
db *sql.DB
cfg config.MaintenanceConfig
logger *logging.Logger
}
// NewMaintenanceService creates a new MaintenanceService.
func NewMaintenanceService(db *sql.DB, cfg config.MaintenanceConfig, logger *logging.Logger) *MaintenanceService {
return &MaintenanceService{db: db, cfg: cfg, logger: logger}
}
// Start runs maintenance once, then on the configured interval until ctx is
// cancelled. It returns immediately; the loop runs in its own goroutine.
func (s *MaintenanceService) Start(ctx context.Context) {
interval := time.Duration(s.cfg.IntervalHours) * time.Hour
if interval <= 0 {
interval = 24 * time.Hour
}
go func() {
s.RunOnce()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.RunOnce()
}
}
}()
}
// RunOnce performs a single maintenance pass: delete rule runs (and their
// action detail) and audit events older than their retention windows, then
// VACUUM to reclaim space. Retention of 0 or less disables that prune.
func (s *MaintenanceService) RunOnce() {
now := time.Now().UTC()
if s.cfg.RunRetentionDays > 0 {
cutoff := now.AddDate(0, 0, -s.cfg.RunRetentionDays).Format(time.RFC3339)
// Delete action detail first so the rows are gone even if FK cascade is
// unavailable, then the runs themselves.
actions := s.exec(
`DELETE FROM rule_run_actions WHERE rule_run_id IN (SELECT id FROM rule_runs WHERE started_utc < ?)`,
cutoff)
runs := s.exec(`DELETE FROM rule_runs WHERE started_utc < ?`, cutoff)
if runs > 0 || actions > 0 {
s.logger.Info("Maintenance", "Pruned %d rule run(s) and %d action record(s) older than %d day(s)",
runs, actions, s.cfg.RunRetentionDays)
}
}
if s.cfg.AuditRetentionDays > 0 {
cutoff := now.AddDate(0, 0, -s.cfg.AuditRetentionDays).Format(time.RFC3339)
if n := s.exec(`DELETE FROM audit_events WHERE created_utc < ?`, cutoff); n > 0 {
s.logger.Info("Maintenance", "Pruned %d audit event(s) older than %d day(s)", n, s.cfg.AuditRetentionDays)
}
}
if s.cfg.Vacuum {
if _, err := s.db.Exec("VACUUM"); err != nil {
s.logger.Warn("Maintenance", "VACUUM failed: %v", err)
}
}
}
// exec runs a DELETE and returns the affected row count (0 on error, logged).
func (s *MaintenanceService) exec(query string, args ...any) int64 {
res, err := s.db.Exec(query, args...)
if err != nil {
s.logger.Warn("Maintenance", "maintenance query failed: %v", err)
return 0
}
n, _ := res.RowsAffected()
return n
}
@@ -0,0 +1,85 @@
package services
import (
"path/filepath"
"testing"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/config"
"github.com/Grace-Solutions/OrchestrAD/internal/db"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
)
func TestMaintenancePrunesOldHistory(t *testing.T) {
database, err := db.New(config.DatabaseConfig{
Path: filepath.Join(t.TempDir(), "maint_test.db"),
MaxOpenConns: 2, MaxIdleConns: 2, WALMode: true, ForeignKeys: true,
}, logging.Default())
if err != nil {
t.Fatalf("db.New: %v", err)
}
defer database.Close()
if err := database.Migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
conn := database.Conn()
old := time.Now().UTC().AddDate(0, 0, -120).Format(time.RFC3339)
recent := time.Now().UTC().AddDate(0, 0, -1).Format(time.RFC3339)
// A connection + rule to satisfy rule_runs FK; a rule_action for actions FK.
if _, err := conn.Exec(`INSERT INTO ad_connections (id,name,is_enabled,hosts,port,root_dn,default_search_scope,timeout_seconds,paging_enabled,page_size,created_utc,updated_utc) VALUES ('c1','c',1,'h',389,'DC=x','Subtree',15,0,0,?,?)`, recent, recent); err != nil {
t.Fatalf("conn: %v", err)
}
if _, err := conn.Exec(`INSERT INTO rules (id,name,is_enabled,ad_connection_id,object_type,execution_mode,group_join_operator,max_parallelism,stop_on_error,created_utc,updated_utc) VALUES ('r1','r',1,'c1','User','Apply','AND',1,0,?,?)`, recent, recent); err != nil {
t.Fatalf("rule: %v", err)
}
if _, err := conn.Exec(`INSERT INTO rule_actions (id,rule_id,action_type,is_enabled,sort_order,configuration_json,created_utc,updated_utc) VALUES ('a1','r1','AddToGroup',1,0,'{}',?,?)`, recent, recent); err != nil {
t.Fatalf("action: %v", err)
}
// Old + recent runs, each with an action row.
mkRun := func(id, started string) {
if _, err := conn.Exec(`INSERT INTO rule_runs (id,rule_id,status,started_utc,execution_mode,created_utc) VALUES (?,?,?,?,?,?)`, id, "r1", "Completed", started, "Apply", started); err != nil {
t.Fatalf("run %s: %v", id, err)
}
if _, err := conn.Exec(`INSERT INTO rule_run_actions (id,rule_run_id,rule_action_id,object_dn,action_type,status,created_utc) VALUES (?,?,?,?,?,?,?)`, "act-"+id, id, "a1", "CN=x", "AddToGroup", "Succeeded", started); err != nil {
t.Fatalf("run action %s: %v", id, err)
}
}
mkRun("old", old)
mkRun("new", recent)
// Old + recent audit events.
if _, err := conn.Exec(`INSERT INTO audit_events (id,event_type,component,action,success,created_utc) VALUES ('ae-old','X','C','A',1,?)`, old); err != nil {
t.Fatalf("audit old: %v", err)
}
if _, err := conn.Exec(`INSERT INTO audit_events (id,event_type,component,action,success,created_utc) VALUES ('ae-new','X','C','A',1,?)`, recent); err != nil {
t.Fatalf("audit new: %v", err)
}
svc := NewMaintenanceService(conn, config.MaintenanceConfig{
RunRetentionDays: 30, AuditRetentionDays: 30, IntervalHours: 24, Vacuum: true,
}, logging.Default())
svc.RunOnce()
count := func(q string) int {
var n int
if err := conn.QueryRow(q).Scan(&n); err != nil {
t.Fatalf("count %q: %v", q, err)
}
return n
}
if got := count(`SELECT COUNT(*) FROM rule_runs`); got != 1 {
t.Errorf("rule_runs = %d, want 1 (recent kept)", got)
}
if got := count(`SELECT COUNT(*) FROM rule_run_actions`); got != 1 {
t.Errorf("rule_run_actions = %d, want 1 (old cascaded away)", got)
}
if got := count(`SELECT COUNT(*) FROM audit_events`); got != 1 {
t.Errorf("audit_events = %d, want 1 (recent kept)", got)
}
if count(`SELECT COUNT(*) FROM rule_runs WHERE id='new'`) != 1 {
t.Errorf("recent run should be retained")
}
}
+10
View File
@@ -69,6 +69,16 @@ func (s *RuleService) GetByID(id string) (*models.Rule, error) {
return s.repo.GetByID(id)
}
// ReplaceLogic replaces a rule's condition groups and actions wholesale. Used
// by the rule editor to persist the filter and target-group actions.
func (s *RuleService) ReplaceLogic(ruleID string, groups []models.RuleConditionGroup, actions []models.RuleAction) error {
if err := s.repo.ReplaceLogic(ruleID, groups, actions); err != nil {
return err
}
s.logger.Info("RuleService", "Replaced logic for rule %s (%d groups, %d actions)", ruleID, len(groups), len(actions))
return nil
}
// RuleListItem represents a rule in list views
type RuleListItem struct {
ID string
@@ -0,0 +1,80 @@
// Package services - built-in schedule seeding
package services
import (
"database/sql"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
"github.com/Grace-Solutions/OrchestrAD/internal/repository"
)
// defaultSchedule describes one built-in schedule offered out of the box.
type defaultSchedule struct {
name string
kind string // "Easy" or "Cron"
interval int // for Easy
unit string // for Easy: Minutes/Hours/Days
cron string // for Cron (6-field, UTC)
desc string
}
var builtinSchedules = []defaultSchedule{
{name: "Every 5 minutes", kind: "Easy", interval: 5, unit: "Minutes", desc: "Built-in"},
{name: "Every 15 minutes", kind: "Easy", interval: 15, unit: "Minutes", desc: "Built-in"},
{name: "Every 30 minutes", kind: "Easy", interval: 30, unit: "Minutes", desc: "Built-in"},
{name: "Hourly", kind: "Easy", interval: 1, unit: "Hours", desc: "Built-in"},
{name: "Every 6 hours", kind: "Easy", interval: 6, unit: "Hours", desc: "Built-in"},
{name: "Every 12 hours", kind: "Easy", interval: 12, unit: "Hours", desc: "Built-in"},
{name: "Daily (00:00 UTC)", kind: "Cron", cron: "0 0 0 * * *", desc: "Built-in — every day at midnight UTC"},
{name: "Weekly (Sun 00:00 UTC)", kind: "Cron", cron: "0 0 0 * * 0", desc: "Built-in — every Sunday at midnight UTC"},
}
// EnsureDefaultSchedules idempotently seeds the built-in schedules so operators
// have ready-made cadences (in the Schedules page and the rule editor) without
// hand-building one. Existing schedules with the same name are left untouched;
// deleting a built-in schedule will not resurrect it within the same run but it
// reappears on next startup unless renamed.
func EnsureDefaultSchedules(db *sql.DB, logger *logging.Logger) {
repo := repository.NewScheduleRepository(db)
existing, _, err := repo.List(0, 500)
if err != nil {
logger.Warn("Schedules", "Could not list schedules for seeding: %v", err)
return
}
have := make(map[string]bool, len(existing))
for _, s := range existing {
have[s.Name] = true
}
seeded := 0
for _, d := range builtinSchedules {
if have[d.name] {
continue
}
desc := d.desc
sched := &models.Schedule{
Name: d.name,
Description: &desc,
IsEnabled: true,
ScheduleKind: d.kind,
TimezoneMode: "UTC",
}
if d.kind == "Easy" {
v, u := d.interval, d.unit
sched.EasyIntervalValue = &v
sched.EasyIntervalUnit = &u
} else {
c := d.cron
sched.CronExpression = &c
}
if err := repo.Create(sched); err != nil {
logger.Warn("Schedules", "Failed to seed built-in schedule %q: %v", d.name, err)
continue
}
seeded++
}
if seeded > 0 {
logger.Info("Schedules", "Seeded %d built-in schedule(s)", seeded)
}
}
@@ -0,0 +1,63 @@
// Package services - settings precedence resolution.
//
// Runtime configuration resolves with a fixed precedence:
//
// app_settings (set via the UI/API) > environment variable > built-in default
//
// This lets environment variables seed a working configuration for bootstrap
// (containers, first run, the MSI), while any value an administrator sets in the
// UI is persisted to app_settings and wins from then on. Bootstrap-only settings
// that must be known before the database opens (data path, secret key, listen
// address/port) are NOT resolved here — they stay in package config.
package services
import (
"os"
"strconv"
"strings"
)
// ResolveString returns the effective string value for key: the app_settings
// value if one is set (non-empty), else the environment variable envVar if set,
// else def. A blank envVar skips the environment lookup.
func (s *SettingsService) ResolveString(key, envVar, def string) string {
if setting, err := s.Get(key); err == nil && setting != nil && setting.Value != "" {
return setting.Value
}
if envVar != "" {
if v := os.Getenv(envVar); v != "" {
return v
}
}
return def
}
// ResolveBool resolves a boolean setting with the same precedence. Recognized
// true values (case-insensitive): 1, t, true, yes, on.
func (s *SettingsService) ResolveBool(key, envVar string, def bool) bool {
raw := s.ResolveString(key, envVar, "")
if raw == "" {
return def
}
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "t", "true", "yes", "on":
return true
case "0", "f", "false", "no", "off":
return false
default:
return def
}
}
// ResolveInt resolves an integer setting with the same precedence, falling back
// to def when unset or unparseable.
func (s *SettingsService) ResolveInt(key, envVar string, def int) int {
raw := s.ResolveString(key, envVar, "")
if raw == "" {
return def
}
if i, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil {
return i
}
return def
}
@@ -0,0 +1,82 @@
package services
import (
"database/sql"
"testing"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
_ "modernc.org/sqlite"
)
// newTestSettings spins up an in-memory database with just the app_settings
// table and returns a SettingsService bound to it.
func newTestSettings(t *testing.T) *SettingsService {
t.Helper()
db, err := sql.Open("sqlite", "file:resolver_test?mode=memory&cache=shared&_pragma=foreign_keys(on)")
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec(`
CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
value_type TEXT NOT NULL DEFAULT 'string',
description TEXT,
is_sensitive INTEGER NOT NULL DEFAULT 0,
updated_utc TEXT NOT NULL
)`); err != nil {
t.Fatalf("create table: %v", err)
}
return NewSettingsService(db, logging.Default())
}
func TestResolveStringPrecedence(t *testing.T) {
s := newTestSettings(t)
const key, env = "oidc.provider_url", "ORCHESTRAD_OIDC_PROVIDER_URL"
// 1) nothing set -> default
if got := s.ResolveString(key, env, "def"); got != "def" {
t.Fatalf("no value: got %q, want def", got)
}
// 2) env set -> env wins over default
t.Setenv(env, "from-env")
if got := s.ResolveString(key, env, "def"); got != "from-env" {
t.Fatalf("env value: got %q, want from-env", got)
}
// 3) DB (UI) set -> DB wins over env
if err := s.Upsert(Setting{Key: key, Value: "from-ui", ValueType: "string", UpdatedUTC: time.Now().UTC()}); err != nil {
t.Fatalf("upsert: %v", err)
}
if got := s.ResolveString(key, env, "def"); got != "from-ui" {
t.Fatalf("db value: got %q, want from-ui (UI must win over env)", got)
}
}
func TestResolveBoolAndInt(t *testing.T) {
s := newTestSettings(t)
if got := s.ResolveBool("oidc.enabled", "ORCHESTRAD_OIDC_ENABLED", false); got != false {
t.Errorf("bool default: got %v, want false", got)
}
t.Setenv("ORCHESTRAD_OIDC_ENABLED", "true")
if got := s.ResolveBool("oidc.enabled", "ORCHESTRAD_OIDC_ENABLED", false); got != true {
t.Errorf("bool env: got %v, want true", got)
}
// UI override to off wins over env=true.
_ = s.Upsert(Setting{Key: "oidc.enabled", Value: "off", ValueType: "bool", UpdatedUTC: time.Now().UTC()})
if got := s.ResolveBool("oidc.enabled", "ORCHESTRAD_OIDC_ENABLED", false); got != false {
t.Errorf("bool ui-override: got %v, want false", got)
}
if got := s.ResolveInt("x.count", "ORCHESTRAD_X_COUNT", 42); got != 42 {
t.Errorf("int default: got %d, want 42", got)
}
_ = s.Upsert(Setting{Key: "x.count", Value: "7", ValueType: "int", UpdatedUTC: time.Now().UTC()})
if got := s.ResolveInt("x.count", "ORCHESTRAD_X_COUNT", 42); got != 7 {
t.Errorf("int ui: got %d, want 7", got)
}
}
+437
View File
@@ -0,0 +1,437 @@
// Package tlsmgr manages the lifecycle of the server's TLS certificate. It
// supports three sources, chosen at runtime (UI/env) via a config callback:
//
// auto self-managed CA + leaf, auto-renewed, exported under <data>/tls
// provided an administrator-supplied cert/key (bring-your-own), stored
// under <data>/tls/provided
// windows-store a certificate selected from the Windows "My" store (Windows
// only) referenced by thumbprint
//
// In every mode the server is handed a live certificate through GetCertificate,
// so a mode change or renewal is picked up without a restart (call Reload).
package tlsmgr
import (
"context"
"crypto/tls"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
"github.com/Grace-Solutions/OrchestrAD/internal/pki"
)
// Modes.
const (
ModeAuto = "auto"
ModeProvided = "provided"
ModeWindowsStore = "windows-store"
providedSubdir = "provided"
providedCertFile = "server.crt"
providedKeyFile = "server.key"
providedChainFile = "chain.pem"
)
// Config is the runtime configuration resolved on each Ensure/Reload (so a UI
// change takes effect without restart).
type Config struct {
Mode string // ModeAuto (default), ModeProvided, ModeWindowsStore
PFXPassword string
WindowsThumbprint string
}
// Options holds build-time parameters that do not change at runtime.
type Options struct {
CN string
DNSNames []string
IPs []net.IP
LeafValidity time.Duration
RenewBefore time.Duration
}
// Manager owns the TLS material for the server.
type Manager struct {
dir string
configFn func() Config
cn string
dnsNames []string
ips []net.IP
leafValidity time.Duration
renewBefore time.Duration
logger *logging.Logger
mu sync.RWMutex
current *tls.Certificate
mode string
fallback bool // true when the configured mode failed and auto is serving
}
// New creates a Manager writing to dir (typically <data>/tls). configFn supplies
// the runtime mode/params; a nil configFn means auto mode.
func New(dir string, opts Options, configFn func() Config, logger *logging.Logger) *Manager {
cn, dns, ips := opts.CN, opts.DNSNames, opts.IPs
if cn == "" || len(dns) == 0 {
dcn, ddns, dips := DefaultSANs()
if cn == "" {
cn = dcn
}
if len(dns) == 0 {
dns = ddns
}
if len(ips) == 0 {
ips = dips
}
}
validity := opts.LeafValidity
if validity <= 0 {
validity = pki.DefaultLeafValidity
}
renew := opts.RenewBefore
if renew <= 0 {
renew = validity / 3
}
return &Manager{
dir: dir,
configFn: configFn,
cn: cn,
dnsNames: dns,
ips: ips,
leafValidity: validity,
renewBefore: renew,
logger: logger,
}
}
func (m *Manager) config() Config {
if m.configFn == nil {
return Config{Mode: ModeAuto}
}
c := m.configFn()
if c.Mode == "" {
c.Mode = ModeAuto
}
return c
}
// Ensure installs the current serving certificate according to the configured
// mode. On failure of a non-auto mode it falls back to the self-managed cert so
// the server still comes up over HTTPS.
func (m *Manager) Ensure() error {
cfg := m.config()
switch cfg.Mode {
case ModeProvided:
if err := m.ensureProvided(); err != nil {
m.logErr("provided certificate unavailable (%v); falling back to self-managed", err)
return m.ensureAutoFallback(cfg.PFXPassword, ModeProvided)
}
m.setMode(ModeProvided, false)
m.logf("Serving administrator-provided certificate")
return nil
case ModeWindowsStore:
if err := m.ensureWindowsStore(cfg.WindowsThumbprint); err != nil {
m.logErr("windows-store certificate unavailable (%v); falling back to self-managed", err)
return m.ensureAutoFallback(cfg.PFXPassword, ModeWindowsStore)
}
m.setMode(ModeWindowsStore, false)
m.logf("Serving certificate from the Windows store (thumbprint %s)", cfg.WindowsThumbprint)
return nil
default:
if err := m.ensureAuto(cfg.PFXPassword); err != nil {
return err
}
m.setMode(ModeAuto, false)
return nil
}
}
// Reload re-reads the configured mode and re-applies it. Called after the UI
// changes the TLS configuration.
func (m *Manager) Reload() error { return m.Ensure() }
func (m *Manager) ensureAutoFallback(pfxPassword, attempted string) error {
if err := m.ensureAuto(pfxPassword); err != nil {
return err
}
m.setMode(ModeAuto, true)
return nil
}
// ensureAuto loads-or-generates the CA, reuses or renews the leaf, exports the
// chain, and installs it.
func (m *Manager) ensureAuto(pfxPassword string) error {
root, inter, err := pki.LoadCA(m.dir)
if err != nil {
return err
}
generatedCA := false
if root == nil || inter == nil {
m.logf("Generating self-managed CA (root + intermediate)")
root, inter, err = pki.NewCA("OrchestrAD Root CA", "OrchestrAD Intermediate CA")
if err != nil {
return err
}
generatedCA = true
}
var leaf *pki.CertKey
if !generatedCA {
if existing := m.loadLeaf(m.dir); existing != nil && !m.expiringSoon(existing.Certificate.NotAfter) {
leaf = existing
}
}
if leaf == nil {
m.logf("Issuing server certificate for CN=%s (SANs: %v)", m.cn, m.dnsNames)
leaf, err = pki.IssueLeaf(inter, m.cn, m.dnsNames, m.ips, m.leafValidity)
if err != nil {
return err
}
}
chain := &pki.Chain{Root: root, Intermediate: inter, Leaf: leaf}
if err := chain.Export(m.dir, pfxPassword); err != nil {
return err
}
m.setCurrent(&tls.Certificate{
Certificate: [][]byte{chain.Leaf.DER, chain.Intermediate.DER},
PrivateKey: chain.Leaf.Key,
Leaf: chain.Leaf.Certificate,
})
return nil
}
// ensureProvided loads a bring-your-own cert/key (plus optional chain) written
// under <dir>/provided by the upload API.
func (m *Manager) ensureProvided() error {
pdir := filepath.Join(m.dir, providedSubdir)
certPEM, err := os.ReadFile(filepath.Join(pdir, providedCertFile))
if err != nil {
return err
}
keyPEM, err := os.ReadFile(filepath.Join(pdir, providedKeyFile))
if err != nil {
return err
}
// tls.X509KeyPair validates that the key matches the cert and parses the
// chain if the cert file contains one.
if chain, err := os.ReadFile(filepath.Join(pdir, providedChainFile)); err == nil {
certPEM = append(certPEM, '\n')
certPEM = append(certPEM, chain...)
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return err
}
m.setCurrent(&cert)
return nil
}
// GetCertificate is the tls.Config.GetCertificate callback.
func (m *Manager) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.current, nil
}
// TLSConfig returns a server tls.Config wired to this manager.
func (m *Manager) TLSConfig() *tls.Config {
return &tls.Config{GetCertificate: m.GetCertificate, MinVersion: tls.VersionTLS12}
}
// Info describes the currently served certificate for the status API.
type Info struct {
Mode string `json:"mode"`
Fallback bool `json:"fallback"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
DNSNames []string `json:"dnsNames"`
NotBefore time.Time `json:"notBefore"`
NotAfter time.Time `json:"notAfter"`
}
// Info returns metadata about the current serving certificate.
func (m *Manager) CurrentInfo() Info {
m.mu.RLock()
defer m.mu.RUnlock()
info := Info{Mode: m.mode, Fallback: m.fallback}
if m.current != nil && m.current.Leaf != nil {
l := m.current.Leaf
info.Subject = l.Subject.String()
info.Issuer = l.Issuer.String()
info.DNSNames = l.DNSNames
info.NotBefore = l.NotBefore
info.NotAfter = l.NotAfter
}
return info
}
// Start runs the renewal loop until ctx is cancelled. Only the auto mode renews
// (provided / windows-store certs are managed externally).
func (m *Manager) Start(ctx context.Context) {
ticker := time.NewTicker(12 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.mu.RLock()
mode := m.mode
var exp time.Time
if m.current != nil && m.current.Leaf != nil {
exp = m.current.Leaf.NotAfter
}
m.mu.RUnlock()
if mode != ModeAuto {
continue
}
if exp.IsZero() || m.expiringSoon(exp) {
m.logf("Certificate renewal window reached; re-issuing")
if err := m.Ensure(); err != nil {
m.logErr("certificate renewal failed: %v", err)
}
}
}
}
}
func (m *Manager) expiringSoon(notAfter time.Time) bool {
return time.Now().Add(m.renewBefore).After(notAfter)
}
func (m *Manager) loadLeaf(dir string) *pki.CertKey {
certBytes, err := os.ReadFile(filepath.Join(dir, "server.crt"))
if err != nil {
return nil
}
keyBytes, err := os.ReadFile(filepath.Join(dir, "server.key"))
if err != nil {
return nil
}
cert, err := pki.ParseCertPEM(certBytes)
if err != nil {
return nil
}
key, err := pki.ParseKeyPEM(keyBytes)
if err != nil {
return nil
}
return &pki.CertKey{Certificate: cert, DER: cert.Raw, Key: key}
}
func (m *Manager) setCurrent(cert *tls.Certificate) {
m.mu.Lock()
m.current = cert
m.mu.Unlock()
}
func (m *Manager) setMode(mode string, fallback bool) {
m.mu.Lock()
m.mode = mode
m.fallback = fallback
m.mu.Unlock()
}
func (m *Manager) logf(format string, args ...any) {
if m.logger != nil {
m.logger.Info("TLS", format, args...)
}
}
func (m *Manager) logErr(format string, args ...any) {
if m.logger != nil {
m.logger.Error("TLS", format, args...)
}
}
// DefaultSANs derives certificate SANs from the host:
// - Subject CN: the short hostname.
// - DNS SANs: the short hostname, localhost, the fully-qualified hostname for
// every detected DNS suffix (host.<suffix>), and the DNS suffixes themselves.
// - IP SANs: loopback plus every non-loopback interface address.
//
// Duplicates are removed. DNS suffixes are read from the OS (Windows registry /
// resolv.conf) via dnsSuffixes.
func DefaultSANs() (cn string, dnsNames []string, ips []net.IP) {
host, _ := os.Hostname()
if host == "" {
host = "localhost"
}
short := host
if i := strings.IndexByte(host, '.'); i >= 0 {
short = host[:i]
}
cn = short
dnsSeen := map[string]bool{}
addDNS := func(n string) {
n = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(n, ".")))
if n != "" && !dnsSeen[n] {
dnsSeen[n] = true
dnsNames = append(dnsNames, n)
}
}
addDNS(short)
addDNS("localhost")
if host != short { // the hostname is already an FQDN
addDNS(host)
}
for _, sfx := range dnsSuffixes() {
addDNS(short + "." + sfx)
addDNS(sfx)
}
ipSeen := map[string]bool{}
addIP := func(ip net.IP) {
if ip == nil {
return
}
if k := ip.String(); !ipSeen[k] {
ipSeen[k] = true
ips = append(ips, ip)
}
}
addIP(net.IPv4(127, 0, 0, 1))
addIP(net.IPv6loopback)
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
addIP(ipnet.IP)
}
}
}
return cn, dnsNames, ips
}
// SaveProvided writes a bring-your-own cert (and optional chain) + key under
// <dir>/provided after validating that they form a usable key pair. Returns the
// parsed leaf certificate.
func (m *Manager) SaveProvided(certPEM, keyPEM, chainPEM []byte) error {
full := certPEM
if len(chainPEM) > 0 {
full = append(append([]byte{}, certPEM...), '\n')
full = append(full, chainPEM...)
}
if _, err := tls.X509KeyPair(full, keyPEM); err != nil {
return err
}
pdir := filepath.Join(m.dir, providedSubdir)
if err := os.MkdirAll(pdir, 0o700); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(pdir, providedCertFile), certPEM, 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(pdir, providedKeyFile), keyPEM, 0o600); err != nil {
return err
}
if len(chainPEM) > 0 {
if err := os.WriteFile(filepath.Join(pdir, providedChainFile), chainPEM, 0o644); err != nil {
return err
}
}
return nil
}
+115
View File
@@ -0,0 +1,115 @@
package tlsmgr
import (
"crypto/x509"
"encoding/pem"
"net"
"testing"
"time"
"github.com/Grace-Solutions/OrchestrAD/internal/pki"
)
func testManager(t *testing.T, renewBefore time.Duration) *Manager {
t.Helper()
return New(t.TempDir(), Options{
CN: "orchestrad.local",
DNSNames: []string{"orchestrad.local", "localhost"},
IPs: []net.IP{net.ParseIP("127.0.0.1")},
LeafValidity: time.Hour,
RenewBefore: renewBefore,
}, func() Config {
return Config{Mode: ModeAuto, PFXPassword: "orchestrad"}
}, nil)
}
func TestEnsureAndServe(t *testing.T) {
m := testManager(t, time.Minute)
if err := m.Ensure(); err != nil {
t.Fatalf("Ensure: %v", err)
}
cert, err := m.GetCertificate(nil)
if err != nil || cert == nil || cert.Leaf == nil {
t.Fatalf("GetCertificate returned no cert: cert=%v err=%v", cert, err)
}
if len(cert.Certificate) != 2 {
t.Errorf("expected leaf+intermediate in the chain, got %d certs", len(cert.Certificate))
}
first := cert.Leaf.SerialNumber.String()
// Re-ensure: the leaf is not near expiry, so it is reused.
if err := m.Ensure(); err != nil {
t.Fatalf("second Ensure: %v", err)
}
cert2, _ := m.GetCertificate(nil)
if cert2.Leaf.SerialNumber.String() != first {
t.Error("leaf was re-issued despite not being near expiry")
}
}
func TestRenewalReissues(t *testing.T) {
// renewBefore >> validity forces the leaf to always look "expiring soon",
// so a second Ensure re-issues it.
m := testManager(t, 2*time.Hour)
if err := m.Ensure(); err != nil {
t.Fatalf("Ensure: %v", err)
}
c1, _ := m.GetCertificate(nil)
first := c1.Leaf.SerialNumber.String()
if err := m.Ensure(); err != nil {
t.Fatalf("renew Ensure: %v", err)
}
c2, _ := m.GetCertificate(nil)
if c2.Leaf.SerialNumber.String() == first {
t.Error("leaf should have been re-issued in the renewal window")
}
}
func TestProvidedMode(t *testing.T) {
dir := t.TempDir()
mode := ModeProvided
m := New(dir, Options{}, func() Config { return Config{Mode: mode, PFXPassword: "x"} }, nil)
// Produce a bring-your-own leaf + key (PEM) and its CA chain.
chain, err := pki.GenerateChain("byo.local", []string{"byo.local"}, []net.IP{net.ParseIP("127.0.0.1")}, time.Hour)
if err != nil {
t.Fatalf("GenerateChain: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: chain.Leaf.DER})
keyDER, _ := x509.MarshalPKCS8PrivateKey(chain.Leaf.Key)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
chainPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: chain.Intermediate.DER})
if err := m.SaveProvided(certPEM, keyPEM, chainPEM); err != nil {
t.Fatalf("SaveProvided: %v", err)
}
if err := m.Ensure(); err != nil {
t.Fatalf("Ensure(provided): %v", err)
}
cert, err := m.GetCertificate(nil)
if err != nil || cert == nil || cert.Leaf == nil {
t.Fatalf("no provided cert served: %v", err)
}
if cert.Leaf.Subject.CommonName != "byo.local" {
t.Errorf("served CN = %q, want byo.local", cert.Leaf.Subject.CommonName)
}
if info := m.CurrentInfo(); info.Mode != ModeProvided || info.Fallback {
t.Errorf("info = %+v, want mode=provided fallback=false", info)
}
}
func TestProvidedFallsBackToAuto(t *testing.T) {
// Provided mode with no uploaded cert must fall back to a self-managed cert
// so the server still starts.
m := New(t.TempDir(), Options{LeafValidity: time.Hour}, func() Config {
return Config{Mode: ModeProvided, PFXPassword: "x"}
}, nil)
if err := m.Ensure(); err != nil {
t.Fatalf("Ensure: %v", err)
}
info := m.CurrentInfo()
if info.Mode != ModeAuto || !info.Fallback {
t.Errorf("info = %+v, want mode=auto fallback=true", info)
}
}
+15
View File
@@ -0,0 +1,15 @@
package tlsmgr
import "time"
// StoreCert describes a certificate available in the operating system store
// (currently the Windows "My" store) for the UI to list and select.
type StoreCert struct {
Thumbprint string `json:"thumbprint"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
NotBefore time.Time `json:"notBefore"`
NotAfter time.Time `json:"notAfter"`
HasPrivateKey bool `json:"hasPrivateKey"`
DNSNames []string `json:"dnsNames"`
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !windows
package tlsmgr
import "errors"
// errWindowsOnly is returned when Windows-store operations are attempted on a
// non-Windows platform.
var errWindowsOnly = errors.New("the Windows certificate store is only available on Windows")
func (m *Manager) ensureWindowsStore(string) error { return errWindowsOnly }
// ListWindowsCerts is unavailable off Windows.
func ListWindowsCerts() ([]StoreCert, error) { return nil, errWindowsOnly }
// WindowsStoreSupported reports whether the Windows store is usable here.
func WindowsStoreSupported() bool { return false }
+287
View File
@@ -0,0 +1,287 @@
//go:build windows
// Windows "My" certificate store integration: enumerate certificates for the UI
// to list, and serve a selected certificate (by thumbprint) using its private
// key directly from CNG via a crypto.Signer, so the key never has to be
// exported. RSA keys (PKCS#1 v1.5 and PSS) are supported; legacy CSP keys and
// non-RSA keys are reported as unusable.
package tlsmgr
import (
"crypto"
"crypto/rsa"
"crypto/sha1"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"io"
"strings"
"sync"
"unsafe"
"golang.org/x/sys/windows"
)
const (
certStoreProvSystemW = 10
certStoreReadonlyFlag = 0x00008000
certSystemStoreCurUser = 0x00010000
certSystemStoreLocalMac = 0x00020000
certKeyProvInfoPropID = 2
cryptAcquireSilentFlag = 0x00000040
cryptAcquireOnlyNCryptKeyFlg = 0x00040000
certNCryptKeySpec = 0xFFFFFFFF
bcryptPadPKCS1 = 0x00000002
bcryptPadPSS = 0x00000008
)
var (
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
ncrypt = windows.NewLazySystemDLL("ncrypt.dll")
procCryptAcquireCertificatePrivateKey = crypt32.NewProc("CryptAcquireCertificatePrivateKey")
procCertGetCertificateContextProperty = crypt32.NewProc("CertGetCertificateContextProperty")
procNCryptSignHash = ncrypt.NewProc("NCryptSignHash")
procNCryptFreeObject = ncrypt.NewProc("NCryptFreeObject")
)
// prevKey holds the NCrypt key handle currently in use so it can be released
// when the manager reloads to a new certificate.
var (
prevKeyMu sync.Mutex
prevKey windows.Handle
)
// WindowsStoreSupported reports whether the Windows store is usable here.
func WindowsStoreSupported() bool { return true }
func openMyStore(location uint32) (windows.Handle, error) {
name, err := windows.UTF16PtrFromString("MY")
if err != nil {
return 0, err
}
return windows.CertOpenStore(
certStoreProvSystemW, 0, 0,
location|certStoreReadonlyFlag,
uintptr(unsafe.Pointer(name)),
)
}
func contextDER(ctx *windows.CertContext) []byte {
der := make([]byte, ctx.Length)
copy(der, unsafe.Slice(ctx.EncodedCert, ctx.Length))
return der
}
func hasPrivateKey(ctx *windows.CertContext) bool {
var cb uint32
r, _, _ := procCertGetCertificateContextProperty.Call(
uintptr(unsafe.Pointer(ctx)),
certKeyProvInfoPropID,
0,
uintptr(unsafe.Pointer(&cb)),
)
return r != 0
}
// ListWindowsCerts enumerates the LocalMachine and CurrentUser "My" stores.
func ListWindowsCerts() ([]StoreCert, error) {
var out []StoreCert
seen := map[string]bool{}
for _, loc := range []uint32{certSystemStoreLocalMac, certSystemStoreCurUser} {
store, err := openMyStore(loc)
if err != nil {
continue
}
var prev *windows.CertContext
for {
ctx, err := windows.CertEnumCertificatesInStore(store, prev)
if ctx == nil || err != nil {
break
}
der := contextDER(ctx)
cert, perr := x509.ParseCertificate(der)
if perr == nil {
sum := sha1.Sum(der)
thumb := strings.ToUpper(hex.EncodeToString(sum[:]))
if !seen[thumb] {
seen[thumb] = true
out = append(out, StoreCert{
Thumbprint: thumb,
Subject: cert.Subject.String(),
Issuer: cert.Issuer.String(),
NotBefore: cert.NotBefore,
NotAfter: cert.NotAfter,
HasPrivateKey: hasPrivateKey(ctx),
DNSNames: cert.DNSNames,
})
}
}
prev = ctx
}
windows.CertCloseStore(store, 0)
}
return out, nil
}
// ensureWindowsStore serves the certificate identified by thumbprint from the
// Windows store using a CNG-backed signer.
func (m *Manager) ensureWindowsStore(thumbprint string) error {
if thumbprint == "" {
return fmt.Errorf("no certificate thumbprint configured")
}
want := strings.ToLower(strings.ReplaceAll(thumbprint, " ", ""))
for _, loc := range []uint32{certSystemStoreLocalMac, certSystemStoreCurUser} {
store, err := openMyStore(loc)
if err != nil {
continue
}
var prev *windows.CertContext
for {
ctx, err := windows.CertEnumCertificatesInStore(store, prev)
if ctx == nil || err != nil {
break
}
der := contextDER(ctx)
sum := sha1.Sum(der)
if strings.ToLower(hex.EncodeToString(sum[:])) != want {
prev = ctx
continue
}
// Match. Acquire the CNG key (caller-owned) then release the context
// and store; the key handle stays valid on its own.
cert, perr := x509.ParseCertificate(der)
if perr != nil {
windows.CertCloseStore(store, 0)
return fmt.Errorf("parsing store certificate: %w", perr)
}
key, kerr := acquireNCryptKey(ctx)
windows.CertCloseStore(store, 0)
if kerr != nil {
return kerr
}
signer := &ncryptSigner{handle: key, pub: cert.PublicKey}
m.setCurrent(&tls.Certificate{
Certificate: [][]byte{der},
PrivateKey: signer,
Leaf: cert,
})
replacePrevKey(key)
return nil
}
windows.CertCloseStore(store, 0)
}
return fmt.Errorf("certificate %s not found (or without a usable private key) in the Windows My store", thumbprint)
}
func replacePrevKey(h windows.Handle) {
prevKeyMu.Lock()
old := prevKey
prevKey = h
prevKeyMu.Unlock()
if old != 0 {
procNCryptFreeObject.Call(uintptr(old))
}
}
func acquireNCryptKey(ctx *windows.CertContext) (windows.Handle, error) {
var h windows.Handle
var keySpec uint32
var callerFree int32
r, _, err := procCryptAcquireCertificatePrivateKey.Call(
uintptr(unsafe.Pointer(ctx)),
cryptAcquireSilentFlag|cryptAcquireOnlyNCryptKeyFlg,
0,
uintptr(unsafe.Pointer(&h)),
uintptr(unsafe.Pointer(&keySpec)),
uintptr(unsafe.Pointer(&callerFree)),
)
if r == 0 {
return 0, fmt.Errorf("acquiring private key from store: %v", err)
}
if keySpec != certNCryptKeySpec {
return 0, fmt.Errorf("certificate uses a legacy CSP key, which is not supported (use a CNG/KSP certificate)")
}
return h, nil
}
// ncryptSigner implements crypto.Signer over a CNG (NCrypt) RSA key handle.
type ncryptSigner struct {
handle windows.Handle
pub crypto.PublicKey
}
func (s *ncryptSigner) Public() crypto.PublicKey { return s.pub }
type bcryptPKCS1PaddingInfo struct{ pszAlgID *uint16 }
type bcryptPSSPaddingInfo struct {
pszAlgID *uint16
cbSalt uint32
}
func (s *ncryptSigner) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
if _, ok := s.pub.(*rsa.PublicKey); !ok {
return nil, fmt.Errorf("windows-store signing supports RSA keys only")
}
algID, err := bcryptHashAlg(opts.HashFunc())
if err != nil {
return nil, err
}
var padPtr unsafe.Pointer
var flags uint32
if pss, ok := opts.(*rsa.PSSOptions); ok {
salt := pss.SaltLength
if salt == rsa.PSSSaltLengthAuto || salt == rsa.PSSSaltLengthEqualsHash {
salt = opts.HashFunc().Size()
}
info := bcryptPSSPaddingInfo{pszAlgID: algID, cbSalt: uint32(salt)}
padPtr = unsafe.Pointer(&info)
flags = bcryptPadPSS
} else {
info := bcryptPKCS1PaddingInfo{pszAlgID: algID}
padPtr = unsafe.Pointer(&info)
flags = bcryptPadPKCS1
}
var cb uint32
r, _, _ := procNCryptSignHash.Call(
uintptr(s.handle), uintptr(padPtr),
uintptr(unsafe.Pointer(&digest[0])), uintptr(len(digest)),
0, 0, uintptr(unsafe.Pointer(&cb)), uintptr(flags),
)
if r != 0 {
return nil, fmt.Errorf("NCryptSignHash (size) failed: 0x%x", r)
}
sig := make([]byte, cb)
r, _, _ = procNCryptSignHash.Call(
uintptr(s.handle), uintptr(padPtr),
uintptr(unsafe.Pointer(&digest[0])), uintptr(len(digest)),
uintptr(unsafe.Pointer(&sig[0])), uintptr(cb),
uintptr(unsafe.Pointer(&cb)), uintptr(flags),
)
if r != 0 {
return nil, fmt.Errorf("NCryptSignHash failed: 0x%x", r)
}
return sig[:cb], nil
}
func bcryptHashAlg(h crypto.Hash) (*uint16, error) {
var name string
switch h {
case crypto.SHA256:
name = "SHA256"
case crypto.SHA384:
name = "SHA384"
case crypto.SHA512:
name = "SHA512"
case crypto.SHA1:
name = "SHA1"
default:
return nil, fmt.Errorf("unsupported hash %v for windows-store signing", h)
}
return windows.UTF16PtrFromString(name)
}
+48
View File
@@ -0,0 +1,48 @@
//go:build !windows
package tlsmgr
import (
"bufio"
"os"
"strings"
)
// dnsSuffixes reads the "domain" and "search" directives from /etc/resolv.conf
// so the certificate covers the host's fully-qualified names.
func dnsSuffixes() []string {
f, err := os.Open("/etc/resolv.conf")
if err != nil {
return nil
}
defer f.Close()
var out []string
seen := map[string]bool{}
add := func(s string) {
s = strings.ToLower(strings.Trim(strings.TrimSpace(s), "."))
if s != "" && !seen[s] {
seen[s] = true
out = append(out, s)
}
}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
switch fields[0] {
case "domain", "search":
for _, s := range fields[1:] {
add(s)
}
}
}
return out
}
@@ -0,0 +1,61 @@
//go:build windows
package tlsmgr
import (
"strings"
"golang.org/x/sys/windows/registry"
)
// dnsSuffixes reads the DNS suffixes by which the host may be addressed from the
// Windows TCP/IP registry: the primary domain and global suffix search list,
// plus every adapter's connection-specific suffix (Domain / DhcpDomain). Per-
// adapter suffixes can be set without joining a domain, so they are included.
func dnsSuffixes() []string {
var out []string
seen := map[string]bool{}
add := func(s string) {
s = strings.ToLower(strings.Trim(strings.TrimSpace(s), "."))
if s != "" && !seen[s] {
seen[s] = true
out = append(out, s)
}
}
const base = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters`
// Global: primary domain + suffix search list.
if k, err := registry.OpenKey(registry.LOCAL_MACHINE, base, registry.QUERY_VALUE); err == nil {
for _, name := range []string{"Domain", "NV Domain", "DhcpDomain"} {
if v, _, e := k.GetStringValue(name); e == nil {
add(v)
}
}
if v, _, e := k.GetStringValue("SearchList"); e == nil {
for _, s := range strings.Split(v, ",") {
add(s)
}
}
k.Close()
}
// Per-adapter connection-specific suffixes (statically set or DHCP-assigned).
if ifaces, err := registry.OpenKey(registry.LOCAL_MACHINE, base+`\Interfaces`, registry.ENUMERATE_SUB_KEYS); err == nil {
names, _ := ifaces.ReadSubKeyNames(-1)
ifaces.Close()
for _, n := range names {
ik, err := registry.OpenKey(registry.LOCAL_MACHINE, base+`\Interfaces\`+n, registry.QUERY_VALUE)
if err != nil {
continue
}
for _, name := range []string{"Domain", "DhcpDomain"} {
if v, _, e := ik.GetStringValue(name); e == nil {
add(v)
}
}
ik.Close()
}
}
return out
}
+28 -1
View File
@@ -34,7 +34,13 @@ const (
OperatorNotExists ConditionOperator = "NotExists"
OperatorGreaterThan ConditionOperator = "GreaterThan"
OperatorLessThan ConditionOperator = "LessThan"
OperatorCustomLdap ConditionOperator = "CustomLdap"
// OperatorMemberOf matches objects that are direct members of a group.
OperatorMemberOf ConditionOperator = "MemberOf"
// OperatorMemberOfRecursive matches objects that are members of a group
// directly or transitively (nested groups) using the LDAP_MATCHING_RULE_IN_CHAIN
// (1.2.840.113556.1.4.1941) AD matching rule.
OperatorMemberOfRecursive ConditionOperator = "MemberOfRecursive"
OperatorCustomLdap ConditionOperator = "CustomLdap"
)
// ActionType for rule actions
@@ -46,6 +52,27 @@ const (
ActionAddGroupToGroup ActionType = "AddGroupToGroup"
ActionEnsureGroupExists ActionType = "EnsureGroupExists"
ActionRemoveFromGroupIfNoMatch ActionType = "RemoveFromGroupIfNoLongerMatched"
// ActionSyncGroupMembership reconciles a target group's membership against
// the matched object set as a single set operation: add matched objects
// that are missing, and (depending on SyncMode) remove members that no
// longer match. This is the dynamic-group action.
ActionSyncGroupMembership ActionType = "SyncGroupMembership"
)
// SyncMode controls how ActionSyncGroupMembership reconciles a target group.
type SyncMode string
const (
// SyncModeFull makes the group's membership exactly equal the matched set:
// matched objects are added, and any current member that no longer matches
// is removed — including members added by hand. The rule owns the group.
SyncModeFull SyncMode = "FullSync"
// SyncModeManaged adds matched objects and removes only members that this
// rule previously added (tracked ownership); members added by other means
// are left untouched.
SyncModeManaged SyncMode = "ManagedAdd"
// SyncModeAddOnly only adds matched objects and never removes anyone.
SyncModeAddOnly SyncMode = "AddOnly"
)
// ExecutionMode for rules
+6 -3
View File
@@ -13,7 +13,7 @@ services:
container_name: orchestrad
restart: unless-stopped
ports:
- "8080:8080"
- "18090:18090"
volumes:
- orchestrad-data:/data
environment:
@@ -21,9 +21,12 @@ services:
- ORCHESTRAD_LOG_LEVEL=info
- ORCHESTRAD_SECRET_KEY=${ORCHESTRAD_SECRET_KEY:-}
- ORCHESTRAD_HOST=0.0.0.0
- ORCHESTRAD_PORT=8080
- ORCHESTRAD_PORT=18090
# Plain HTTP by default (terminate TLS at your ingress/proxy). Set to true
# to have the container manage its own self-signed certificate.
- ORCHESTRAD_TLS_ENABLED=false
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:18090/health"]
interval: 30s
timeout: 10s
retries: 3
+110
View File
@@ -0,0 +1,110 @@
<#
.SYNOPSIS
Creates an OrchestrAD dynamic-group rule through the REST API.
.DESCRIPTION
Demonstrates authenticating to OrchestrAD and creating a rule that syncs all
users whose department is "Sales" into a target group (creating the group if
missing, and keeping its membership exactly in step with the filter).
Style notes (as requested):
* Body and headers are built as strongly-typed dictionaries via
New-Object with the full type name.
* The body is converted to JSON with ConvertTo-Json.
* Full cmdlet names are used throughout — no aliases.
Requires PowerShell 7+ (uses -SkipCertificateCheck for the self-signed dev
certificate; drop that switch when a trusted certificate is in use).
.EXAMPLE
./Create-OrchestrADRule.ps1 -BaseUrl 'https://localhost:18090' -Username 'admin' -Password 'admin' -ConnectionId '57feb058-...' -TargetGroupDn 'CN=Sales,OU=Groups,DC=corp,DC=com'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $BaseUrl,
[Parameter(Mandatory = $true)] [string] $Username,
[Parameter(Mandatory = $true)] [string] $Password,
[Parameter(Mandatory = $true)] [string] $ConnectionId,
[Parameter(Mandatory = $true)] [string] $TargetGroupDn,
[Parameter(Mandatory = $false)] [string] $Department = 'Sales'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# --- 1. Authenticate and obtain a bearer token ----------------------------------
$loginBody = New-Object 'System.Collections.Generic.Dictionary[String,Object]'
$loginBody.Add('username', $Username)
$loginBody.Add('password', $Password)
$loginResponse = Invoke-RestMethod -Method 'Post' -Uri "$BaseUrl/api/v1/auth/login" `
-ContentType 'application/json' -Body ($loginBody | ConvertTo-Json) -SkipCertificateCheck
$token = $loginResponse.data.token
Write-Output "Authenticated as '$Username'."
# --- 2. Build request headers as a dictionary -----------------------------------
$headers = New-Object 'System.Collections.Generic.Dictionary[String,String]'
$headers.Add('Authorization', "Bearer $token")
$headers.Add('Accept', 'application/json')
# --- 3. Build the rule body as nested dictionaries / lists ----------------------
# One condition: department equals $Department.
$condition = New-Object 'System.Collections.Generic.Dictionary[String,Object]'
$condition.Add('attributeName', 'department')
$condition.Add('operator', 'Equals')
$condition.Add('comparisonValue', $Department)
$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)
# One action: sync membership to the target group (full add + remove).
$actionConfig = New-Object 'System.Collections.Generic.Dictionary[String,Object]'
$actionConfig.Add('targetGroupDn', $TargetGroupDn)
$actionConfig.Add('syncMode', 'FullSync')
$actionConfig.Add('createIfMissing', $true)
$actionConfig.Add('groupScope', 'Global')
$actionConfig.Add('groupType', 'Security')
$action = New-Object 'System.Collections.Generic.Dictionary[String,Object]'
$action.Add('actionType', 'SyncGroupMembership')
# configurationJson is a JSON *string*, so serialize the config dictionary.
$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 - $Department")
$ruleBody.Add('description', 'Created via the OrchestrAD REST API')
$ruleBody.Add('adConnectionId', $ConnectionId)
$ruleBody.Add('objectType', 'User')
$ruleBody.Add('executionMode', 'Apply')
$ruleBody.Add('groupJoinOperator', 'AND')
$ruleBody.Add('conditionGroups', $conditionGroups)
$ruleBody.Add('actions', $actions)
# --- 4. Create the rule ---------------------------------------------------------
$json = $ruleBody | ConvertTo-Json -Depth 10
$rule = Invoke-RestMethod -Method 'Post' -Uri "$BaseUrl/api/v1/rules" `
-Headers $headers -ContentType 'application/json' -Body $json -SkipCertificateCheck
Write-Output ("Created rule '{0}' (id {1})." -f $rule.data.name, $rule.data.id)
# --- 5. (Optional) run it now ---------------------------------------------------
$run = Invoke-RestMethod -Method 'Post' -Uri "$BaseUrl/api/v1/rules/$($rule.data.id)/run" `
-Headers $headers -SkipCertificateCheck
Write-Output ("Run status: {0}" -f $run.data.status)
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

@@ -0,0 +1,38 @@
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="256" y2="256" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#5b5ef0"/>
<stop offset="1" stop-color="#7c3aed"/>
</linearGradient>
<radialGradient id="glow" cx="0.32" cy="0.26" r="0.9">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.28"/>
<stop offset="0.55" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
<filter id="soft" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="3" stdDeviation="4" flood-color="#1e1b4b" flood-opacity="0.35"/>
</filter>
</defs>
<!-- Badge -->
<rect x="8" y="8" width="240" height="240" rx="52" fill="url(#bg)"/>
<rect x="8" y="8" width="240" height="240" rx="52" fill="url(#glow)"/>
<!-- Orchestration graph: a central hub conducting four directory nodes -->
<g stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.95">
<line x1="128" y1="128" x2="128" y2="66"/>
<line x1="128" y1="128" x2="190" y2="128"/>
<line x1="128" y1="128" x2="128" y2="190"/>
<line x1="128" y1="128" x2="66" y2="128"/>
</g>
<g filter="url(#soft)">
<!-- Satellite nodes -->
<circle cx="128" cy="60" r="20" fill="#ffffff"/>
<circle cx="196" cy="128" r="20" fill="#ffffff"/>
<circle cx="128" cy="196" r="20" fill="#ffffff"/>
<circle cx="60" cy="128" r="20" fill="#ffffff"/>
<!-- Central hub -->
<circle cx="128" cy="128" r="30" fill="#ffffff"/>
<circle cx="128" cy="128" r="14" fill="#6d40e8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,104 @@
"use client";
import Chip from "@mui/material/Chip";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { ActivityRecord } from "@/lib/api/types";
import { formatDateTime, formatDurationMs, runStatusColor } from "@/lib/format";
import { categoryColor } from "./categories";
function prettyJson(raw?: string): string | null {
if (!raw) return null;
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Stack direction="row" spacing={2} justifyContent="space-between" alignItems="flex-start">
<Typography variant="body2" color="textSecondary" sx={{ minWidth: 120 }}>
{label}
</Typography>
<Typography variant="body2" sx={{ textAlign: "right", wordBreak: "break-word" }}>
{value}
</Typography>
</Stack>
);
}
export default function ActivityDetailDialog({
record,
onClose,
}: {
record: ActivityRecord | null;
onClose: () => void;
}) {
const details = prettyJson(record?.detailsJson);
return (
<Dialog open={record != null} onClose={onClose} maxWidth="sm" fullWidth>
{record && (
<>
<DialogTitle>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<span>{record.actionType}</span>
<Chip size="small" label={record.category} color={categoryColor(record.category)} variant="outlined" />
<Chip size="small" label={record.status} color={runStatusColor(record.status)} />
</Stack>
</DialogTitle>
<DialogContent dividers>
<Stack spacing={1.5}>
<Field label="Object (to what)" value={record.objectDn} />
<Field label="Action (what)" value={record.actionType} />
<Field label="Result (what happened)" value={record.status} />
<Field label="Rule" value={record.ruleName} />
<Field label="Triggered by (who)" value={record.triggeredBy ?? "—"} />
<Field label="When" value={formatDateTime(record.createdUtc)} />
<Field label="Duration" value={formatDurationMs(record.durationMs)} />
{record.errorMessage && (
<>
<Divider />
<Typography variant="subtitle2" color="error.main">Error</Typography>
<Typography variant="body2" color="error.main" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{record.errorMessage}
</Typography>
</>
)}
{details && (
<>
<Divider />
<Typography variant="subtitle2">Details</Typography>
<Typography
component="pre"
variant="body2"
sx={{
m: 0,
p: 1.5,
borderRadius: 1,
bgcolor: "action.hover",
fontFamily: "monospace",
fontSize: 12,
overflowX: "auto",
whiteSpace: "pre",
}}
>
{details}
</Typography>
</>
)}
</Stack>
</DialogContent>
</>
)}
</Dialog>
);
}
@@ -0,0 +1,30 @@
// Shared category presentation helpers for the activity intelligence page.
import type { ChipColor } from "@/lib/format";
export const CATEGORIES = ["Sync", "Operation", "Removal"] as const;
export function categoryColor(category: string): ChipColor {
switch (category) {
case "Sync":
return "info";
case "Removal":
return "warning";
case "Operation":
return "secondary";
default:
return "default";
}
}
export function categoryLabel(category: string): string {
switch (category) {
case "Sync":
return "Syncs";
case "Operation":
return "Operations";
case "Removal":
return "Removals";
default:
return category;
}
}
+285
View File
@@ -0,0 +1,285 @@
"use client";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Grid from "@mui/material/Grid";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TablePagination from "@mui/material/TablePagination";
import TableRow from "@mui/material/TableRow";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import RefreshOutlinedIcon from "@mui/icons-material/RefreshOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { useCallback, useEffect, useState } from "react";
import PageContainer from "@/app/components/container/PageContainer";
import DashboardCard from "@/app/components/shared/DashboardCard";
import { ApiError } from "@/lib/api/client";
import { ActivityApi } from "@/lib/api/resources";
import type { ActivityRecord, ActivitySummary, ActivityWindow } from "@/lib/api/types";
import { formatDateTime, runStatusColor } from "@/lib/format";
import ActivityDetailDialog from "./ActivityDetailDialog";
import { CATEGORIES, categoryColor, categoryLabel } from "./categories";
const STATUS_OPTIONS = ["", "Succeeded", "Failed"];
function WindowCard({ w }: { w: ActivityWindow }) {
const stat = (label: string, value: number, color?: string) => (
<Box sx={{ minWidth: 64 }}>
<Typography variant="h5" color={color}>{value}</Typography>
<Typography variant="caption" color="textSecondary">{label}</Typography>
</Box>
);
return (
<DashboardCard title={w.label} subtitle={`${w.objectsAffected} object(s) affected`}>
<Stack direction="row" spacing={2} flexWrap="wrap" rowGap={1.5}>
{stat("Total", w.total)}
{stat("Syncs", w.syncs, "info.main")}
{stat("Operations", w.operations, "secondary.main")}
{stat("Removals", w.removals, "warning.main")}
{stat("Failed", w.failed, "error.main")}
</Stack>
</DashboardCard>
);
}
export default function ActivityPage() {
const [summary, setSummary] = useState<ActivitySummary | null>(null);
const [items, setItems] = useState<ActivityRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(25);
const [category, setCategory] = useState("");
const [status, setStatus] = useState("");
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<ActivityRecord | null>(null);
const loadSummary = useCallback(() => {
ActivityApi.summary().then(setSummary).catch(() => setSummary(null));
}, []);
const loadFeed = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await ActivityApi.list({
page: page + 1,
pageSize,
category: category || undefined,
status: status || undefined,
search: search || undefined,
});
setItems(res.items);
setTotal(Number(res.meta?.totalCount ?? res.items.length));
} catch (err) {
setError(err instanceof ApiError ? err.message : "Failed to load activity");
} finally {
setLoading(false);
}
}, [page, pageSize, category, status, search]);
useEffect(() => { loadSummary(); }, [loadSummary]);
useEffect(() => { void loadFeed(); }, [loadFeed]);
const refresh = () => { loadSummary(); void loadFeed(); };
return (
<PageContainer title="Activity" description="What changed in your directory, and why">
<Box>
{/* Summary windows */}
<Grid container spacing={3} sx={{ mb: 1 }}>
{(summary?.windows ?? []).map((w) => (
<Grid key={w.key} size={{ xs: 12, md: 4 }}>
<WindowCard w={w} />
</Grid>
))}
{/* By action type roll-up */}
<Grid size={{ xs: 12, lg: 6 }}>
<DashboardCard title="By action type" subtitle="All-time totals">
{summary && summary.byActionType.length > 0 ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Action</TableCell>
<TableCell>Category</TableCell>
<TableCell align="right">Total</TableCell>
<TableCell align="right">Ok</TableCell>
<TableCell align="right">Failed</TableCell>
</TableRow>
</TableHead>
<TableBody>
{summary.byActionType.map((a) => (
<TableRow key={a.actionType} hover>
<TableCell>{a.actionType}</TableCell>
<TableCell>
<Chip size="small" variant="outlined" label={a.category} color={categoryColor(a.category)} />
</TableCell>
<TableCell align="right">{a.total}</TableCell>
<TableCell align="right">{a.success}</TableCell>
<TableCell align="right">
{a.failed > 0 ? <Typography component="span" color="error">{a.failed}</Typography> : 0}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography variant="body2" color="textSecondary">No activity recorded yet.</Typography>
)}
</DashboardCard>
</Grid>
{/* Most active rules */}
<Grid size={{ xs: 12, lg: 6 }}>
<DashboardCard title="Most active rules" subtitle="By actions performed">
{summary && summary.topRules.length > 0 ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Rule</TableCell>
<TableCell align="right">Actions</TableCell>
<TableCell align="right">Failed</TableCell>
</TableRow>
</TableHead>
<TableBody>
{summary.topRules.map((r) => (
<TableRow key={r.ruleId} hover>
<TableCell>{r.ruleName}</TableCell>
<TableCell align="right">{r.total}</TableCell>
<TableCell align="right">
{r.failed > 0 ? <Typography component="span" color="error">{r.failed}</Typography> : 0}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography variant="body2" color="textSecondary">No activity recorded yet.</Typography>
)}
</DashboardCard>
</Grid>
</Grid>
{/* Drill-in feed */}
<DashboardCard
title="Activity feed"
subtitle="What action, what happened, to which object, by whom, and when"
action={
<Tooltip title="Refresh">
<IconButton onClick={refresh} disabled={loading}>
<RefreshOutlinedIcon />
</IconButton>
</Tooltip>
}
>
<Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>
)}
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ mb: 2 }}>
<TextField
select label="Category" size="small" sx={{ minWidth: 160 }}
value={category} onChange={(e) => { setCategory(e.target.value); setPage(0); }}
>
<MenuItem value=""><em>All categories</em></MenuItem>
{CATEGORIES.map((c) => <MenuItem key={c} value={c}>{categoryLabel(c)}</MenuItem>)}
</TextField>
<TextField
select label="Result" size="small" sx={{ minWidth: 150 }}
value={status} onChange={(e) => { setStatus(e.target.value); setPage(0); }}
>
{STATUS_OPTIONS.map((s) => (
<MenuItem key={s || "all"} value={s}>{s || "All results"}</MenuItem>
))}
</TextField>
<TextField
label="Object contains" size="small" sx={{ minWidth: 240 }}
value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
placeholder="CN=…,OU=…"
/>
</Stack>
{loading && items.length === 0 ? (
<Box display="flex" justifyContent="center" py={4}><CircularProgress /></Box>
) : items.length === 0 ? (
<Typography variant="body2" color="textSecondary">No activity matches the current filters.</Typography>
) : (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>When</TableCell>
<TableCell>Category</TableCell>
<TableCell>Action</TableCell>
<TableCell>Object</TableCell>
<TableCell>Rule</TableCell>
<TableCell>Result</TableCell>
<TableCell>By</TableCell>
<TableCell align="right">Details</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((a) => (
<TableRow key={a.id} hover>
<TableCell sx={{ whiteSpace: "nowrap" }}>{formatDateTime(a.createdUtc)}</TableCell>
<TableCell>
<Chip size="small" variant="outlined" label={a.category} color={categoryColor(a.category)} />
</TableCell>
<TableCell>{a.actionType}</TableCell>
<TableCell sx={{ maxWidth: 320, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
<Tooltip title={a.objectDn}><span>{a.objectDn}</span></Tooltip>
</TableCell>
<TableCell>{a.ruleName}</TableCell>
<TableCell>
<Chip size="small" label={a.status} color={runStatusColor(a.status)} variant="outlined" />
</TableCell>
<TableCell>{a.triggeredBy ?? "—"}</TableCell>
<TableCell align="right">
<Tooltip title="View details">
<IconButton size="small" onClick={() => setDetail(a)}>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(0); }}
rowsPerPageOptions={[10, 25, 50, 100]}
/>
</TableContainer>
)}
</Box>
</DashboardCard>
</Box>
<ActivityDetailDialog record={detail} onClose={() => setDetail(null)} />
</PageContainer>
);
}
@@ -8,6 +8,7 @@ import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
@@ -27,6 +28,7 @@ interface Props {
export default function ApiKeyCreateDialog({ open, userId, onClose, onCreated }: Props) {
const [name, setName] = useState("");
const [scope, setScope] = useState("readwrite");
const [expiresAt, setExpiresAt] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -36,6 +38,7 @@ export default function ApiKeyCreateDialog({ open, userId, onClose, onCreated }:
useEffect(() => {
if (!open) return;
setName("");
setScope("readwrite");
setExpiresAt("");
setError(null);
setFullKey(null);
@@ -50,6 +53,7 @@ export default function ApiKeyCreateDialog({ open, userId, onClose, onCreated }:
const res = await ApiKeysApi.create({
name: name.trim(),
userId,
scope,
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : undefined,
});
setFullKey(res.key);
@@ -84,6 +88,13 @@ export default function ApiKeyCreateDialog({ open, userId, onClose, onCreated }:
label="Name" value={name} onChange={(e) => setName(e.target.value)}
required fullWidth placeholder="e.g. CI/CD integration"
/>
<TextField
select label="Scope" value={scope} onChange={(e) => setScope(e.target.value)} fullWidth
helperText="Read = GET only. Read/Write = full access."
>
<MenuItem value="readwrite">Read / Write (full access)</MenuItem>
<MenuItem value="read">Read only</MenuItem>
</TextField>
<TextField
label="Expires At (optional)" type="datetime-local"
value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)}
+6
View File
@@ -141,6 +141,7 @@ export default function ApiKeysPage() {
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Prefix</TableCell>
<TableCell>Scope</TableCell>
<TableCell>Status</TableCell>
<TableCell>Enabled</TableCell>
<TableCell>Created</TableCell>
@@ -164,6 +165,11 @@ export default function ApiKeysPage() {
</Stack>
</TableCell>
<TableCell sx={{ fontFamily: "monospace", fontSize: 12 }}>{k.keyPrefix}&hellip;</TableCell>
<TableCell>
<Chip size="small" variant="outlined"
label={k.scope === "read" ? "Read" : "Read/Write"}
color={k.scope === "read" ? "default" : "info"} />
</TableCell>
<TableCell>
{revoked ? <Chip size="small" label="Revoked" color="error" variant="outlined" />
: expired ? <Chip size="small" label="Expired" color="warning" variant="outlined" />
@@ -2,6 +2,7 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Link from "next/link";
import BlankCard from "@/app/components/shared/BlankCard";
type ColorKey =
@@ -17,13 +18,17 @@ interface Props {
total: number;
enabled: number;
color?: ColorKey;
href?: string;
}
const CountTile = ({ label, total, enabled, color = "primary" }: Props) => {
const CountTile = ({ label, total, enabled, color = "primary", href }: Props) => {
const disabled = Math.max(0, total - enabled);
return (
const body = (
<BlankCard>
<Box p={3}>
<Box
p={3}
sx={href ? { transition: "background-color .15s", "&:hover": { bgcolor: "action.hover" } } : undefined}
>
<Typography variant="subtitle2" color="textSecondary" mb={1}>
{label}
</Typography>
@@ -41,6 +46,13 @@ const CountTile = ({ label, total, enabled, color = "primary" }: Props) => {
</Box>
</BlankCard>
);
if (!href) return body;
return (
<Link href={href} style={{ textDecoration: "none", color: "inherit", display: "block" }}>
{body}
</Link>
);
};
export default CountTile;
@@ -12,7 +12,6 @@ import { IconMenu2 } from "@tabler/icons-react";
import Notifications from "../../vertical/header/Notification";
import Profile from "../../vertical/header/Profile";
import Search from "../../vertical/header/Search";
import Language from "../../vertical/header/Language";
import Logo from "../../shared/logo/Logo";
import config from "@/app/context/config";
@@ -77,7 +76,6 @@ export default function Header() {
{/* Search Dropdown */}
{/* ------------------------------------------- */}
<Search />
<Language />
<IconButton size="large" color="inherit">
{activeMode === 'light' ? (
<Icon icon="solar:moon-line-duotone" width="21" height="21" onClick={() => setActiveMode("dark")} />
@@ -18,6 +18,7 @@ const Menuitems = [
{ id: uniqueId(), title: 'Rules', href: '/rules' },
{ id: uniqueId(), title: 'Schedules', href: '/schedules' },
{ id: uniqueId(), title: 'Rule Runs', href: '/rule-runs' },
{ id: uniqueId(), title: 'Activity', href: '/activity' },
],
},
{
@@ -1,67 +1,46 @@
'use client'
import Link from "next/link";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { styled } from '@mui/material/styles';
import Image from "next/image";
import { useContext } from "react";
import { CustomizerContext } from "@/app/context/customizerContext";
import config from "@/app/context/config";
// OrchestrAD wordmark: the app icon plus the product name. Collapses to just the
// icon in the mini sidebar. Works in both light and dark themes.
export default function Logo() {
const { isCollapse, isSidebarHover, activeDir, activeMode } = useContext(CustomizerContext);
const { isCollapse, isSidebarHover } = useContext(CustomizerContext);
const TopbarHeight = config.topbarHeight;
const compact = isCollapse === "mini-sidebar" && !isSidebarHover;
const LinkStyled = styled(Link)(() => ({
height: TopbarHeight,
width: isCollapse == "mini-sidebar" && !isSidebarHover ? "40px" : "180px",
width: compact ? "40px" : "180px",
overflow: "hidden",
display: "block",
display: "flex",
alignItems: "center",
gap: "10px",
textDecoration: "none",
}));
if (activeDir === "ltr") {
return (
<LinkStyled href="/">
{activeMode === "dark" ? (
<Image
src="/images/logos/logo-light.svg"
alt="logo"
height={TopbarHeight}
width={174}
priority
/>
) : (
<Image
src={"/images/logos/logo-dark.svg"}
alt="logo"
height={TopbarHeight}
width={174}
priority
/>
)}
</LinkStyled>
);
}
return (
<LinkStyled href="/">
{activeMode === "dark" ? (
<Image
src="/images/logos/logo-light-rtl.svg"
alt="logo"
height={TopbarHeight}
width={174}
priority
/>
) : (
<Image
src="/images/logos/logo-dark-rtl.svg"
alt="logo"
height={TopbarHeight}
width={174}
priority
/>
)}
<Box
component="img"
src="/images/logos/orchestrad.svg"
alt="OrchestrAD"
sx={{ width: 34, height: 34, flexShrink: 0 }}
/>
{!compact ? (
<Typography
variant="h5"
sx={{ fontWeight: 700, letterSpacing: "-0.01em", color: "text.primary", whiteSpace: "nowrap" }}
>
OrchestrAD
</Typography>
) : null}
</LinkStyled>
);
}
@@ -11,7 +11,6 @@ import { Icon } from "@iconify/react";
import Notifications from "./Notification";
import Profile from "./Profile";
import Search from "./Search";
import Language from "./Language";
import { shadows } from "@/utils/theme/Shadows";
@@ -79,7 +78,6 @@ const Header = () => {
<Box flexGrow={1} />
<Stack spacing={2} direction="row" alignItems="center">
{smUp ? <Search /> : ""}
<Language />
<IconButton size="large" color="inherit">
{activeMode === 'light' ? (
<Icon icon="solar:moon-line-duotone" width="21" height="21" onClick={() => setActiveMode("dark")} />
@@ -1,98 +0,0 @@
import React, { useContext } from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';
import { CustomizerContext } from '@/app/context/customizerContext';
import { Stack } from '@mui/system';
import { useTranslation } from 'react-i18next';
import { useEffect } from 'react';
const Languages = [
{
flagname: 'English (UK)',
icon: "/images/flag/icon-flag-en.svg",
value: 'en',
},
{
flagname: '中国人 (Chinese)',
icon: "/images/flag/icon-flag-cn.svg",
value: 'ch',
},
{
flagname: 'français (French)',
icon: "/images/flag/icon-flag-fr.svg",
value: 'fr',
},
{
flagname: 'عربي (Arabic)',
icon: "/images/flag/icon-flag-sa.svg",
value: 'ar',
},
];
const Language = () => {
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const { isLanguage, setIsLanguage } = useContext(CustomizerContext);
const open = Boolean(anchorEl);
const currentLang =
Languages.find((_lang) => _lang.value === isLanguage) || Languages[1];
const { i18n } = useTranslation();
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
useEffect(() => {
i18n.changeLanguage(isLanguage);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<Button
aria-label="more"
id="long-button" className="btn-rounded-circle-40"
aria-controls={open ? 'long-menu' : undefined}
aria-expanded={open ? 'true' : undefined}
aria-haspopup="true" color="inherit"
onClick={handleClick}
>
<Avatar src={currentLang.icon} alt={currentLang.value} sx={{ width: 20, height: 20 }} />
</Button>
<Menu
id="long-menu"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
sx={{
'& .MuiMenu-paper': {
width: '200px',
},
}}
>
{Languages.map((option, index) => (
<MenuItem
key={index}
sx={{ py: 2, px: 3 }}
onClick={() => setIsLanguage(option.value)}
>
<Stack direction="row" spacing={1} alignItems="center">
<Avatar src={option.icon} alt={option.icon} sx={{ width: 20, height: 20 }} />
<Typography> {option.flagname}</Typography>
</Stack>
</MenuItem>
))}
</Menu>
</>
);
};
export default Language;
@@ -70,7 +70,7 @@ const Search = () => {
borderRadius: "25px",
}}
>
Try to searching
Search
</Button>
<Menu
id="basic-menu"
@@ -38,6 +38,13 @@ const Menuitems: NavGroup[] = [
href: "/rule-runs",
bgcolor: "secondary",
},
{
id: uniqueId(),
title: "Activity",
icon: "chart-2-line-duotone",
href: "/activity",
bgcolor: "primary",
},
{
navlabel: true,
subheader: "Directory",
@@ -81,6 +88,13 @@ const Menuitems: NavGroup[] = [
href: "/audit",
bgcolor: "warning",
},
{
id: uniqueId(),
title: "Security",
icon: "shield-check-line-duotone",
href: "/security",
bgcolor: "primary",
},
{
id: uniqueId(),
title: "Settings",
@@ -9,6 +9,7 @@ import { useTheme } from "@mui/material/styles";
import SidebarItems from "./SidebarItems";
import Logo from "../../shared/logo/Logo";
import { CustomizerContext } from "@/app/context/customizerContext";
import { AuthContext } from "@/app/context/AuthContext";
import config from '@/app/context/config'
import Scrollbar from "@/app/components/custom-scroll/Scrollbar";
@@ -16,6 +17,14 @@ import Scrollbar from "@/app/components/custom-scroll/Scrollbar";
import { Icon } from "@iconify/react";
import { useContext } from "react";
function initials(name?: string): string {
if (!name) return "?";
const parts = name.trim().split(/[\s._-]+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
export default function Sidebar() {
const lgUp = useMediaQuery((theme) => theme.breakpoints.down("lg"));
const {
@@ -26,6 +35,7 @@ export default function Sidebar() {
setIsMobileSidebar,
isCardShadow
} = useContext(CustomizerContext);
const { session, logout } = useContext(AuthContext);
const MiniSidebarWidth = config.miniSidebarWidth;
const SidebarWidth = config.sidebarWidth;
@@ -117,29 +127,28 @@ export default function Sidebar() {
<SidebarItems />
</Scrollbar>
{isCollapse == "mini-sidebar" ? null : (
<Box px={3} py={2} m={3} bgcolor="primary.light">
<Box px={3} py={2} m={3} bgcolor="primary.light" borderRadius={2}>
<Stack
direction="row"
gap={2}
justifyContent="space-between"
alignItems="center"
>
<Box display="flex" alignItems="center">
<Avatar
src={"/images/profile/user1.jpg"}
sx={{ width: 45, height: 45 }}
/>
<Box ml={2}>
<Typography variant="h5">Mike</Typography>
<Typography variant="subtitle1">Admin</Typography>
<Box display="flex" alignItems="center" minWidth={0}>
<Avatar sx={{ width: 42, height: 42, bgcolor: "primary.main", fontSize: 16 }}>
{initials(session?.user?.displayName || session?.user?.username)}
</Avatar>
<Box ml={2} minWidth={0}>
<Typography variant="h6" noWrap>
{session?.user?.displayName || session?.user?.username || "Signed in"}
</Typography>
<Typography variant="subtitle2" color="textSecondary" noWrap>
{session?.user?.roles?.[0] || "User"}
</Typography>
</Box>
</Box>
<IconButton color="primary" href="/login">
<Icon
icon="solar:logout-line-duotone"
width={24}
height={24}
/>
<IconButton color="primary" onClick={() => logout()} aria-label="Sign out">
<Icon icon="solar:logout-line-duotone" width={24} height={24} />
</IconButton>
</Stack>
</Box>
+55 -44
View File
@@ -6,6 +6,7 @@ import CircularProgress from "@mui/material/CircularProgress";
import Grid from "@mui/material/Grid";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Link from "next/link";
import { useEffect, useState } from "react";
import PageContainer from "@/app/components/container/PageContainer";
@@ -17,6 +18,8 @@ import { DashboardApi } from "@/lib/api/resources";
import { ApiError } from "@/lib/api/client";
import type { DashboardSummary } from "@/lib/api/types";
const cardLink: React.CSSProperties = { textDecoration: "none", color: "inherit", display: "block" };
export default function Dashboard() {
const [data, setData] = useState<DashboardSummary | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -74,72 +77,80 @@ export default function Dashboard() {
<Box>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
<CountTile label="Rules" total={counts.rules.total} enabled={counts.rules.enabled} color="primary" />
<CountTile label="Rules" total={counts.rules.total} enabled={counts.rules.enabled} color="primary" href="/rules" />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
<CountTile label="Connections" total={counts.connections.total} enabled={counts.connections.enabled} color="warning" />
<CountTile label="Connections" total={counts.connections.total} enabled={counts.connections.enabled} color="warning" href="/connections" />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
<CountTile label="Credentials" total={counts.credentials.total} enabled={counts.credentials.enabled} color="error" />
<CountTile label="Credentials" total={counts.credentials.total} enabled={counts.credentials.enabled} color="error" href="/credentials" />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
<CountTile label="Schedules" total={counts.schedules.total} enabled={counts.schedules.enabled} color="success" />
<CountTile label="Schedules" total={counts.schedules.total} enabled={counts.schedules.enabled} color="success" href="/schedules" />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
<CountTile label="Users" total={counts.users.total} enabled={counts.users.enabled} color="secondary" />
<CountTile label="Users" total={counts.users.total} enabled={counts.users.enabled} color="secondary" href="/users" />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 2 }}>
<CountTile label="API Keys" total={counts.apiKeys.total} enabled={counts.apiKeys.enabled} color="info" />
<CountTile label="API Keys" total={counts.apiKeys.total} enabled={counts.apiKeys.enabled} color="info" href="/api-keys" />
</Grid>
<Grid size={{ xs: 12, lg: 6 }}>
<DashboardCard title="Rule Runs (last 24h)" subtitle="Success vs. failures">
<Stack direction="row" spacing={4} alignItems="baseline">
<Box>
<Typography variant="h3">{ruleRunStats.last24hTotal}</Typography>
<Typography variant="subtitle2" color="textSecondary">Total</Typography>
</Box>
<Box>
<Typography variant="h4" color="success.main">{ruleRunStats.last24hSuccess}</Typography>
<Typography variant="subtitle2" color="textSecondary">Success</Typography>
</Box>
<Box>
<Typography variant="h4" color="error.main">{ruleRunStats.last24hFailed}</Typography>
<Typography variant="subtitle2" color="textSecondary">Failed</Typography>
</Box>
</Stack>
</DashboardCard>
<Link href="/activity" style={cardLink}>
<DashboardCard title="Rule Runs (last 24h)" subtitle="Success vs. failures — open Activity">
<Stack direction="row" spacing={4} alignItems="baseline">
<Box>
<Typography variant="h3">{ruleRunStats.last24hTotal}</Typography>
<Typography variant="subtitle2" color="textSecondary">Total</Typography>
</Box>
<Box>
<Typography variant="h4" color="success.main">{ruleRunStats.last24hSuccess}</Typography>
<Typography variant="subtitle2" color="textSecondary">Success</Typography>
</Box>
<Box>
<Typography variant="h4" color="error.main">{ruleRunStats.last24hFailed}</Typography>
<Typography variant="subtitle2" color="textSecondary">Failed</Typography>
</Box>
</Stack>
</DashboardCard>
</Link>
</Grid>
<Grid size={{ xs: 12, lg: 6 }}>
<DashboardCard title="Rule Runs (last 7d)" subtitle="Success vs. failures">
<Stack direction="row" spacing={4} alignItems="baseline">
<Box>
<Typography variant="h3">{ruleRunStats.last7dTotal}</Typography>
<Typography variant="subtitle2" color="textSecondary">Total</Typography>
</Box>
<Box>
<Typography variant="h4" color="success.main">{ruleRunStats.last7dSuccess}</Typography>
<Typography variant="subtitle2" color="textSecondary">Success</Typography>
</Box>
<Box>
<Typography variant="h4" color="error.main">{ruleRunStats.last7dFailed}</Typography>
<Typography variant="subtitle2" color="textSecondary">Failed</Typography>
</Box>
</Stack>
</DashboardCard>
<Link href="/activity" style={cardLink}>
<DashboardCard title="Rule Runs (last 7d)" subtitle="Success vs. failures — open Activity">
<Stack direction="row" spacing={4} alignItems="baseline">
<Box>
<Typography variant="h3">{ruleRunStats.last7dTotal}</Typography>
<Typography variant="subtitle2" color="textSecondary">Total</Typography>
</Box>
<Box>
<Typography variant="h4" color="success.main">{ruleRunStats.last7dSuccess}</Typography>
<Typography variant="subtitle2" color="textSecondary">Success</Typography>
</Box>
<Box>
<Typography variant="h4" color="error.main">{ruleRunStats.last7dFailed}</Typography>
<Typography variant="subtitle2" color="textSecondary">Failed</Typography>
</Box>
</Stack>
</DashboardCard>
</Link>
</Grid>
<Grid size={{ xs: 12, lg: 7 }}>
<DashboardCard title="Recent Rule Runs" subtitle="Latest 10 executions">
<RecentRunsTable rows={recentRuns} />
</DashboardCard>
<Link href="/rule-runs" style={cardLink}>
<DashboardCard title="Recent Rule Runs" subtitle="Latest 10 executions — open Rule Runs">
<RecentRunsTable rows={recentRuns} />
</DashboardCard>
</Link>
</Grid>
<Grid size={{ xs: 12, lg: 5 }}>
<DashboardCard title="Connection Health" subtitle="Last test results">
<ConnectionHealthList items={connectionsHealth} />
</DashboardCard>
<Link href="/connections" style={cardLink}>
<DashboardCard title="Connection Health" subtitle="Last test results — open Connections">
<ConnectionHealthList items={connectionsHealth} />
</DashboardCard>
</Link>
</Grid>
</Grid>
</Box>
@@ -0,0 +1,107 @@
"use client";
import Autocomplete from "@mui/material/Autocomplete";
import CircularProgress from "@mui/material/CircularProgress";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { useEffect, useMemo, useRef, useState } from "react";
import { ConnectionsApi } from "@/lib/api/resources";
import type { AttributeInfo, AttributeMeta } from "@/lib/api/types";
interface Props {
connectionId: string;
objectType: string;
common: AttributeMeta[]; // curated suggestions shown before the user types
value: string;
onChange: (attr: string) => void;
}
// AttributePicker is a search-or-type control for an LDAP attribute: it shows
// the curated common attributes up front and live-searches the connection's
// full directory schema as the operator types, so any attribute is reachable.
export default function AttributePicker({ connectionId, objectType, common, value, onChange }: Props) {
const [input, setInput] = useState("");
const [schema, setSchema] = useState<AttributeInfo[]>([]);
const [loading, setLoading] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!connectionId) return;
const q = input.trim();
if (q.length < 2) {
setSchema([]);
return;
}
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
setLoading(true);
ConnectionsApi.attributes(connectionId, objectType, q, 50)
.then(setSchema)
.catch(() => setSchema([]))
.finally(() => setLoading(false));
}, 300);
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, [connectionId, objectType, input]);
// Merge curated + schema results, de-duplicated by name; curated first.
const options = useMemo(() => {
const map = new Map<string, AttributeInfo>();
common.forEach((a) => map.set(a.name.toLowerCase(), { name: a.name, description: a.label }));
schema.forEach((a) => {
if (!map.has(a.name.toLowerCase())) map.set(a.name.toLowerCase(), a);
});
return Array.from(map.values());
}, [common, schema]);
return (
<Autocomplete<AttributeInfo | string, false, false, true>
freeSolo
size="small"
sx={{ minWidth: 220, flex: 1 }}
options={options}
loading={loading}
filterOptions={(x) => x}
value={value}
onChange={(_, v) => onChange(v == null ? "" : typeof v === "string" ? v : v.name)}
onInputChange={(_, v, reason) => {
if (reason === "input") {
setInput(v);
onChange(v);
}
}}
getOptionLabel={(o) => (typeof o === "string" ? o : o.name)}
isOptionEqualToValue={(o, v) => (typeof o === "string" ? o === v : o.name === v)}
renderOption={(props, o) => {
const name = typeof o === "string" ? o : o.name;
const desc = typeof o === "string" ? undefined : o.description;
return (
<li {...props} key={name}>
<div>
<Typography variant="body2">{name}</Typography>
{desc && <Typography variant="caption" color="textSecondary">{desc}</Typography>}
</div>
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
label="Attribute"
placeholder="e.g. department"
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loading ? <CircularProgress color="inherit" size={16} /> : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
/>
);
}
@@ -0,0 +1,102 @@
"use client";
import Autocomplete from "@mui/material/Autocomplete";
import CircularProgress from "@mui/material/CircularProgress";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { useEffect, useMemo, useRef, useState } from "react";
import { ConnectionsApi } from "@/lib/api/resources";
import type { DirectoryObject } from "@/lib/api/types";
interface Props {
connectionId: string;
type: "Group" | "OU";
value: string; // a DN or canonical string
onChange: (dn: string) => void;
label: string;
disabled?: boolean;
helperText?: string;
}
// DirectoryPicker is a search-or-type control: it live-searches the directory
// for groups/OUs as the operator types, but also accepts a hand-entered DN or
// canonical path (freeSolo), so a target can be selected or specified manually.
export default function DirectoryPicker({ connectionId, type, value, onChange, label, disabled, helperText }: Props) {
const [input, setInput] = useState("");
const [options, setOptions] = useState<DirectoryObject[]>([]);
const [loading, setLoading] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!connectionId || disabled) return;
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
setLoading(true);
ConnectionsApi.directory(connectionId, type, input, 25)
.then(setOptions)
.catch(() => setOptions([]))
.finally(() => setLoading(false));
}, 300);
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, [connectionId, type, input, disabled]);
const labelByDn = useMemo(() => {
const m = new Map<string, DirectoryObject>();
options.forEach((o) => m.set(o.dn, o));
return m;
}, [options]);
return (
<Autocomplete<DirectoryObject | string, false, false, true>
freeSolo
disabled={disabled}
options={options}
loading={loading}
filterOptions={(x) => x} // server-side filtering
value={value}
onChange={(_, v) => {
if (v == null) onChange("");
else if (typeof v === "string") onChange(v);
else onChange(v.dn);
}}
onInputChange={(_, v, reason) => {
if (reason === "input") setInput(v);
}}
getOptionLabel={(o) => (typeof o === "string" ? o : o.dn)}
isOptionEqualToValue={(o, v) => (typeof o === "string" ? o === v : o.dn === v)}
renderOption={(props, o) => {
const obj = typeof o === "string" ? labelByDn.get(o) : o;
return (
<li {...props} key={typeof o === "string" ? o : o.dn}>
<div>
<Typography variant="body2">{obj?.name ?? (typeof o === "string" ? o : o.name)}</Typography>
<Typography variant="caption" color="textSecondary">
{obj?.canonicalName ?? (typeof o === "string" ? o : o.dn)}
</Typography>
</div>
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
label={label}
placeholder={connectionId ? "Search or paste a DN / canonical path" : "Select a connection first"}
helperText={helperText}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{loading ? <CircularProgress color="inherit" size={16} /> : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
/>
);
}
@@ -0,0 +1,598 @@
"use client";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import FormControlLabel from "@mui/material/FormControlLabel";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import AddOutlinedIcon from "@mui/icons-material/AddOutlined";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
import { useCallback, useEffect, useState } from "react";
import { ApiError } from "@/lib/api/client";
import { ConnectionsApi, RulesApi, SchedulesApi } from "@/lib/api/resources";
import type {
ADConnection,
DirectoryObject,
RuleInput,
RuleMetadata,
RulePreviewResult,
Schedule,
} from "@/lib/api/types";
import DirectoryPicker from "./DirectoryPicker";
import AttributePicker from "./AttributePicker";
import ValuePicker from "./ValuePicker";
interface Props {
open: boolean;
ruleId: string | null;
onClose: () => void;
onSaved: () => void | Promise<void>;
}
interface CondDraft {
attributeName: string;
operator: string;
value: string;
negate: boolean;
}
interface GroupDraft {
name: string;
joinOperator: string;
negate: boolean;
conditions: CondDraft[];
}
interface ActionDraft {
actionType: string;
targetGroupDn: string;
targetOu: string;
syncMode: string;
createIfMissing: boolean;
groupScope: string;
groupType: string;
}
type ScheduleMode = "manual" | "existing" | "interval" | "cron";
const newCondition = (): CondDraft => ({ attributeName: "", operator: "Equals", value: "", negate: false });
const newGroup = (): GroupDraft => ({ name: "", joinOperator: "AND", negate: false, conditions: [newCondition()] });
const newAction = (): ActionDraft => ({
actionType: "SyncGroupMembership",
targetGroupDn: "",
targetOu: "",
syncMode: "FullSync",
createIfMissing: true,
groupScope: "Global",
groupType: "Security",
});
export default function RuleEditorDialog({ open, ruleId, onClose, onSaved }: Props) {
const [tab, setTab] = useState(0);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [meta, setMeta] = useState<RuleMetadata | null>(null);
const [connections, setConnections] = useState<ADConnection[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
// Scalar fields
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [isEnabled, setIsEnabled] = useState(true);
const [adConnectionId, setAdConnectionId] = useState("");
const [objectType, setObjectType] = useState("User");
const [baseDnOverride, setBaseDnOverride] = useState("");
const [searchScopeOverride, setSearchScopeOverride] = useState("");
const [executionMode, setExecutionMode] = useState("Apply");
const [groupJoinOperator, setGroupJoinOperator] = useState("AND");
const [stopOnError, setStopOnError] = useState(false);
// Logic
const [groups, setGroups] = useState<GroupDraft[]>([newGroup()]);
const [actions, setActions] = useState<ActionDraft[]>([newAction()]);
// Schedule
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>("manual");
const [scheduleId, setScheduleId] = useState("");
const [intervalValue, setIntervalValue] = useState(1);
const [intervalUnit, setIntervalUnit] = useState("Hours");
const [cronExpression, setCronExpression] = useState("0 0 * * * *");
// Preview
const [preview, setPreview] = useState<RulePreviewResult | null>(null);
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setTab(0);
setError(null);
setPreview(null);
setPreviewError(null);
// reset
setName(""); setDescription(""); setIsEnabled(true); setAdConnectionId("");
setObjectType("User"); setBaseDnOverride(""); setSearchScopeOverride("");
setExecutionMode("Apply"); setGroupJoinOperator("AND"); setStopOnError(false);
setGroups([newGroup()]); setActions([newAction()]);
setScheduleMode("manual"); setScheduleId(""); setIntervalValue(1); setIntervalUnit("Hours");
setCronExpression("0 0 * * * *");
RulesApi.metadata().then(setMeta).catch(() => setMeta(null));
ConnectionsApi.list({ pageSize: 200 }).then((r) => setConnections(r.items)).catch(() => setConnections([]));
SchedulesApi.list({ pageSize: 200 }).then((r) => setSchedules(r.items)).catch(() => setSchedules([]));
if (ruleId) {
setLoading(true);
RulesApi.get(ruleId)
.then((r) => {
setName(r.name); setDescription(r.description ?? ""); setIsEnabled(r.isEnabled);
setAdConnectionId(r.adConnectionId); setObjectType(r.objectType);
setBaseDnOverride(r.baseDnOverride ?? ""); setSearchScopeOverride(r.searchScopeOverride ?? "");
setExecutionMode(r.executionMode); setGroupJoinOperator(r.groupJoinOperator);
setStopOnError(r.stopOnError);
setGroups(
r.conditionGroups.length
? r.conditionGroups.map((g) => ({
name: g.name ?? "",
joinOperator: g.joinOperator,
negate: g.negate,
conditions: g.conditions.length
? g.conditions.map((c) => ({
attributeName: c.attributeName,
operator: c.operator,
value: c.operator === "CustomLdap" ? (c.customLdapExpression ?? "") : (c.comparisonValue ?? ""),
negate: c.negate,
}))
: [newCondition()],
}))
: [newGroup()],
);
setActions(
r.actions.length
? r.actions.map((a) => {
let cfg: Record<string, unknown> = {};
try { cfg = JSON.parse(a.configurationJson || "{}"); } catch { /* ignore */ }
return {
actionType: a.actionType,
targetGroupDn: String(cfg.targetGroupDn ?? ""),
targetOu: String(cfg.targetOu ?? ""),
syncMode: String(cfg.syncMode ?? "FullSync"),
createIfMissing: Boolean(cfg.createIfMissing ?? cfg.createOuIfMissing ?? true),
groupScope: String(cfg.groupScope ?? "Global"),
groupType: String(cfg.groupType ?? "Security"),
};
})
: [newAction()],
);
if (r.scheduleId) { setScheduleMode("existing"); setScheduleId(r.scheduleId); }
})
.catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load rule"))
.finally(() => setLoading(false));
}
}, [open, ruleId]);
const operators = meta?.operators ?? [];
const opNeedsValue = useCallback(
(op: string) => operators.find((o) => o.value === op)?.needsValue ?? true,
[operators],
);
const opIsCustom = useCallback(
(op: string) => operators.find((o) => o.value === op)?.custom ?? false,
[operators],
);
const buildBody = (): RuleInput => ({
name: name.trim(),
description: description.trim() || undefined,
isEnabled,
adConnectionId,
objectType,
baseDnOverride: baseDnOverride.trim() || undefined,
searchScopeOverride: searchScopeOverride || undefined,
executionMode,
groupJoinOperator,
stopOnError,
conditionGroups: groups
.filter((g) => g.conditions.some((c) => c.attributeName.trim() || c.value.trim()))
.map((g) => ({
name: g.name || undefined,
joinOperator: g.joinOperator,
negate: g.negate,
isEnabled: true,
conditions: g.conditions
.filter((c) => c.attributeName.trim() || c.value.trim() || !opNeedsValue(c.operator))
.map((c) => ({
attributeName: c.attributeName.trim(),
operator: c.operator,
negate: c.negate,
isEnabled: true,
comparisonValue: opIsCustom(c.operator) ? undefined : (opNeedsValue(c.operator) ? c.value : undefined),
customLdapExpression: opIsCustom(c.operator) ? c.value : undefined,
})),
})),
actions: actions.map((a) => ({
actionType: a.actionType,
isEnabled: true,
configurationJson: JSON.stringify(actionConfig(a)),
})),
});
const runPreview = async () => {
setPreviewing(true);
setPreviewError(null);
try {
const body = buildBody();
if (!body.adConnectionId) throw new Error("Select an AD connection first");
setPreview(await RulesApi.previewSpec(body));
} catch (err) {
setPreviewError(err instanceof ApiError ? err.message : (err as Error).message);
setPreview(null);
} finally {
setPreviewing(false);
}
};
const ensureScheduleId = async (): Promise<string | undefined> => {
if (scheduleMode === "manual") return undefined;
if (scheduleMode === "existing") return scheduleId || undefined;
// create a schedule then return its id
const body: Partial<Schedule> =
scheduleMode === "interval"
? {
name: `${name.trim() || "Rule"} — every ${intervalValue} ${intervalUnit.toLowerCase()}`,
isEnabled: true, scheduleKind: "Easy",
easyIntervalValue: intervalValue, easyIntervalUnit: intervalUnit, timezoneMode: "UTC",
}
: {
name: `${name.trim() || "Rule"} — cron`,
isEnabled: true, scheduleKind: "Cron", cronExpression, timezoneMode: "UTC",
};
const created = await SchedulesApi.create(body);
return created.id;
};
const handleSave = async () => {
if (!name.trim()) { setTab(0); return setError("Name is required"); }
if (!adConnectionId) { setTab(0); return setError("AD connection is required"); }
setSaving(true);
setError(null);
try {
const sid = await ensureScheduleId();
const body = { ...buildBody(), scheduleId: sid };
if (ruleId) await RulesApi.update(ruleId, body);
else await RulesApi.create(body);
await onSaved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Failed to save rule");
} finally {
setSaving(false);
}
};
// ---- render helpers ----
const setGroup = (gi: number, patch: Partial<GroupDraft>) =>
setGroups((gs) => gs.map((g, i) => (i === gi ? { ...g, ...patch } : g)));
const setCond = (gi: number, ci: number, patch: Partial<CondDraft>) =>
setGroups((gs) => gs.map((g, i) => (i === gi ? { ...g, conditions: g.conditions.map((c, j) => (j === ci ? { ...c, ...patch } : c)) } : g)));
const setAction = (ai: number, patch: Partial<ActionDraft>) =>
setActions((as) => as.map((a, i) => (i === ai ? { ...a, ...patch } : a)));
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
<DialogTitle>{ruleId ? "Edit dynamic group rule" : "New dynamic group rule"}</DialogTitle>
<DialogContent dividers sx={{ minHeight: 460 }}>
{loading ? (
<Stack alignItems="center" py={6}><CircularProgress /></Stack>
) : (
<>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }} variant="scrollable" scrollButtons="auto">
<Tab label="Scope" />
<Tab label="Filter" />
<Tab label="Target & action" />
<Tab label="Schedule" />
<Tab label="Preview" />
</Tabs>
{/* ---- Scope ---- */}
{tab === 0 && (
<Stack spacing={2}>
<TextField label="Name" value={name} onChange={(e) => setName(e.target.value)} required fullWidth />
<TextField label="Description" value={description} onChange={(e) => setDescription(e.target.value)} fullWidth multiline rows={2} />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField select label="AD Connection" value={adConnectionId} onChange={(e) => setAdConnectionId(e.target.value)} fullWidth required>
{connections.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
</TextField>
<TextField select label="Object type" value={objectType} onChange={(e) => setObjectType(e.target.value)} fullWidth>
{(meta?.objectTypes ?? [{ value: "User", label: "Users" }]).map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
</Stack>
<DirectoryPicker
connectionId={adConnectionId} type="OU" label="Search base (OU)"
value={baseDnOverride} onChange={setBaseDnOverride}
helperText="Where to look. Leave blank to search from the connection root."
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center">
<TextField select label="Search scope" value={searchScopeOverride} onChange={(e) => setSearchScopeOverride(e.target.value)} fullWidth>
<MenuItem value=""><em>(connection default)</em></MenuItem>
{(meta?.searchScopes ?? []).map((s) => <MenuItem key={s.value} value={s.value}>{s.label}</MenuItem>)}
</TextField>
<TextField select label="Execution mode" value={executionMode} onChange={(e) => setExecutionMode(e.target.value)} fullWidth>
<MenuItem value="Apply">Apply (make changes)</MenuItem>
<MenuItem value="PreviewOnly">Preview only (dry-run)</MenuItem>
</TextField>
</Stack>
<Stack direction="row" spacing={3}>
<FormControlLabel control={<Switch checked={isEnabled} onChange={(_, v) => setIsEnabled(v)} />} label="Enabled" />
<FormControlLabel control={<Switch checked={stopOnError} onChange={(_, v) => setStopOnError(v)} />} label="Stop on first error" />
</Stack>
</Stack>
)}
{/* ---- Filter ---- */}
{tab === 1 && (
<Stack spacing={2}>
<Typography variant="body2" color="textSecondary">
Select the objects to sync. Objects match when they satisfy the condition groups below.
</Typography>
{groups.length > 1 && (
<TextField select size="small" label="Combine groups with" value={groupJoinOperator}
onChange={(e) => setGroupJoinOperator(e.target.value)} sx={{ maxWidth: 260 }}>
{(meta?.joinOperators ?? [{ value: "AND", label: "Match ALL of" }, { value: "OR", label: "Match ANY of" }]).map((o) =>
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
)}
{groups.map((g, gi) => (
<Paper key={gi} variant="outlined" sx={{ p: 2 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle2">Group {gi + 1}</Typography>
<TextField select size="small" label="Match" value={g.joinOperator} onChange={(e) => setGroup(gi, { joinOperator: e.target.value })} sx={{ minWidth: 150 }}>
{(meta?.joinOperators ?? [{ value: "AND", label: "Match ALL of" }, { value: "OR", label: "Match ANY of" }]).map((o) =>
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
<FormControlLabel control={<Switch size="small" checked={g.negate} onChange={(_, v) => setGroup(gi, { negate: v })} />} label="NOT" />
<Box flexGrow={1} />
{groups.length > 1 && (
<Tooltip title="Remove group"><IconButton size="small" onClick={() => setGroups((gs) => gs.filter((_, i) => i !== gi))}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
)}
</Stack>
<Stack spacing={1}>
{g.conditions.map((c, ci) => (
<Stack key={ci} direction={{ xs: "column", md: "row" }} spacing={1} alignItems={{ md: "center" }}>
<AttributePicker
connectionId={adConnectionId}
objectType={objectType}
common={meta?.commonAttributes?.[objectType] ?? []}
value={c.attributeName}
onChange={(v) => setCond(gi, ci, { attributeName: v })}
/>
<TextField select size="small" label="Operator" value={c.operator} onChange={(e) => setCond(gi, ci, { operator: e.target.value })} sx={{ minWidth: 200 }}>
{operators.map((o) => <MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
{opNeedsValue(c.operator) && (
(c.operator === "MemberOf" || c.operator === "MemberOfRecursive") ? (
<Box sx={{ flex: 1, minWidth: 220 }}>
<DirectoryPicker connectionId={adConnectionId} type="Group" label="Group" value={c.value} onChange={(dn) => setCond(gi, ci, { value: dn })} />
</Box>
) : opIsCustom(c.operator) ? (
<TextField size="small" label="LDAP filter" value={c.value}
onChange={(e) => setCond(gi, ci, { value: e.target.value })} sx={{ flex: 1, minWidth: 180 }}
placeholder={operators.find((o) => o.value === c.operator)?.hint} />
) : (
<ValuePicker
connectionId={adConnectionId}
objectType={objectType}
attribute={c.attributeName}
baseDn={baseDnOverride}
value={c.value}
onChange={(v) => setCond(gi, ci, { value: v })}
/>
)
)}
<FormControlLabel control={<Switch size="small" checked={c.negate} onChange={(_, v) => setCond(gi, ci, { negate: v })} />} label="NOT" />
<Tooltip title="Remove condition">
<span>
<IconButton size="small" disabled={g.conditions.length === 1}
onClick={() => setGroup(gi, { conditions: g.conditions.filter((_, j) => j !== ci) })}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
))}
<Button size="small" startIcon={<AddOutlinedIcon />} onClick={() => setGroup(gi, { conditions: [...g.conditions, newCondition()] })} sx={{ alignSelf: "flex-start" }}>
Add condition
</Button>
</Stack>
</Paper>
))}
<Button startIcon={<AddOutlinedIcon />} onClick={() => setGroups((gs) => [...gs, newGroup()])} sx={{ alignSelf: "flex-start" }}>
Add condition group
</Button>
</Stack>
)}
{/* ---- Target & action ---- */}
{tab === 2 && (
<Stack spacing={2}>
<Typography variant="body2" color="textSecondary">What to do with the matched objects.</Typography>
{actions.map((a, ai) => (
<Paper key={ai} variant="outlined" sx={{ p: 2 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 1.5 }}>
<TextField select size="small" label="Action" value={a.actionType} onChange={(e) => setAction(ai, { actionType: e.target.value })} sx={{ minWidth: 280 }}>
{(meta?.actionTypes ?? [{ value: "SyncGroupMembership", label: "Sync membership to group" }]).map((o) =>
<MenuItem key={o.value} value={o.value}>{o.label}</MenuItem>)}
</TextField>
<Typography variant="caption" color="textSecondary" sx={{ flex: 1 }}>
{meta?.actionTypes?.find((t) => t.value === a.actionType)?.description}
</Typography>
{actions.length > 1 && (
<Tooltip title="Remove action"><IconButton size="small" onClick={() => setActions((as) => as.filter((_, i) => i !== ai))}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
)}
</Stack>
{(a.actionType === "SyncGroupMembership" || a.actionType === "AddToGroup" || a.actionType === "EnsureGroupExists") && (
<Stack spacing={2}>
<DirectoryPicker connectionId={adConnectionId} type="Group" label="Target group"
value={a.targetGroupDn} onChange={(dn) => setAction(ai, { targetGroupDn: dn })}
helperText="Search, or paste a group DN / canonical path." />
{a.actionType === "SyncGroupMembership" && (
<TextField select size="small" label="Sync mode" value={a.syncMode} onChange={(e) => setAction(ai, { syncMode: e.target.value })} fullWidth
helperText={meta?.syncModes?.find((m) => m.value === a.syncMode)?.description}>
{(meta?.syncModes ?? [{ value: "FullSync", label: "Full sync (add + remove)" }]).map((m) =>
<MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}
</TextField>
)}
<Stack direction="row" spacing={2} alignItems="center">
<FormControlLabel control={<Switch checked={a.createIfMissing} onChange={(_, v) => setAction(ai, { createIfMissing: v })} />} label="Create group if missing" />
{a.createIfMissing && (
<>
<TextField select size="small" label="Scope" value={a.groupScope} onChange={(e) => setAction(ai, { groupScope: e.target.value })} sx={{ minWidth: 140 }}>
{["Global", "DomainLocal", "Universal"].map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
</TextField>
<TextField select size="small" label="Type" value={a.groupType} onChange={(e) => setAction(ai, { groupType: e.target.value })} sx={{ minWidth: 150 }}>
{["Security", "Distribution"].map((s) => <MenuItem key={s} value={s}>{s}</MenuItem>)}
</TextField>
</>
)}
</Stack>
</Stack>
)}
{a.actionType === "MoveToOu" && (
<Stack spacing={2}>
<DirectoryPicker connectionId={adConnectionId} type="OU" label="Target OU"
value={a.targetOu} onChange={(dn) => setAction(ai, { targetOu: dn })}
helperText="Move each matched object into this OU." />
<FormControlLabel control={<Switch checked={a.createIfMissing} onChange={(_, v) => setAction(ai, { createIfMissing: v })} />} label="Create OU path if missing" />
</Stack>
)}
</Paper>
))}
<Button startIcon={<AddOutlinedIcon />} onClick={() => setActions((as) => [...as, newAction()])} sx={{ alignSelf: "flex-start" }}>
Add action
</Button>
</Stack>
)}
{/* ---- Schedule ---- */}
{tab === 3 && (
<Stack spacing={2} sx={{ maxWidth: 520 }}>
<TextField select label="Run" value={scheduleMode} onChange={(e) => setScheduleMode(e.target.value as ScheduleMode)}>
<MenuItem value="manual">Manually only</MenuItem>
<MenuItem value="existing">On an existing schedule</MenuItem>
<MenuItem value="interval">On an interval (create)</MenuItem>
<MenuItem value="cron">On a cron expression (create)</MenuItem>
</TextField>
{scheduleMode === "existing" && (
<TextField select label="Schedule" value={scheduleId} onChange={(e) => setScheduleId(e.target.value)}>
<MenuItem value=""><em>Select</em></MenuItem>
{schedules.map((s) => <MenuItem key={s.id} value={s.id}>{s.name}</MenuItem>)}
</TextField>
)}
{scheduleMode === "interval" && (
<Stack direction="row" spacing={2} alignItems="center">
<Typography>Every</Typography>
<TextField type="number" size="small" value={intervalValue} onChange={(e) => setIntervalValue(Math.max(1, Number(e.target.value)))} sx={{ width: 100 }} />
<TextField select size="small" value={intervalUnit} onChange={(e) => setIntervalUnit(e.target.value)} sx={{ minWidth: 140 }}>
{(meta?.scheduleUnits ?? [{ value: "Hours", label: "hours" }]).map((u) => <MenuItem key={u.value} value={u.value}>{u.label}</MenuItem>)}
</TextField>
</Stack>
)}
{scheduleMode === "cron" && (
<TextField label="Cron expression" value={cronExpression} onChange={(e) => setCronExpression(e.target.value)}
helperText="6-field (with seconds), UTC. e.g. '0 0 * * * *' = top of every hour." />
)}
</Stack>
)}
{/* ---- Preview ---- */}
{tab === 4 && (
<Stack spacing={2}>
<Stack direction="row" spacing={2} alignItems="center">
<Button variant="contained" startIcon={<PlayArrowOutlinedIcon />} onClick={runPreview} disabled={previewing}>
{previewing ? "Running…" : "Preview matches"}
</Button>
<Typography variant="caption" color="textSecondary">Dry-run against the directory makes no changes.</Typography>
</Stack>
{previewError && <Alert severity="error">{previewError}</Alert>}
{preview && (
<>
<Alert severity="info" icon={false}>
<Typography variant="body2"><strong>{preview.matchedObjects.length}</strong> object(s) match.</Typography>
<Typography variant="caption" sx={{ fontFamily: "monospace", wordBreak: "break-all" }}>{preview.generatedFilter}</Typography>
</Alert>
{(preview.warnings ?? []).map((w, i) => <Alert key={i} severity="warning">{w}</Alert>)}
<Typography variant="subtitle2">Planned changes</Typography>
<Stack spacing={0.5} sx={{ maxHeight: 160, overflowY: "auto" }}>
{preview.plannedActions
.filter((p) => p.actionType === "SyncGroupMembership")
.map((p, i) => (
<Typography key={`s${i}`} variant="caption" sx={{ fontFamily: "monospace" }}>{p.description}</Typography>
))}
{preview.plannedActions.length === 0 && <Typography variant="caption" color="textSecondary">No changes.</Typography>}
</Stack>
<Typography variant="subtitle2">Sample matches</Typography>
<Stack spacing={0.5} sx={{ maxHeight: 200, overflowY: "auto" }}>
{preview.matchedObjects.slice(0, 20).map((m) => (
<Typography key={m.dn} variant="caption" sx={{ fontFamily: "monospace" }}>
{m.canonicalName || m.dn}
</Typography>
))}
{preview.matchedObjects.length > 20 && (
<Typography variant="caption" color="textSecondary">and {preview.matchedObjects.length - 20} more</Typography>
)}
</Stack>
</>
)}
</Stack>
)}
</>
)}
</DialogContent>
<DialogActions>
<Box flexGrow={1} sx={{ pl: 1 }}>
<Chip size="small" variant="outlined" label={`${groups.reduce((n, g) => n + g.conditions.length, 0)} condition(s) · ${actions.length} action(s)`} />
</Box>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={handleSave} variant="contained" disabled={saving || loading}>
{saving ? "Saving…" : ruleId ? "Save" : "Create"}
</Button>
</DialogActions>
</Dialog>
);
}
function actionConfig(a: ActionDraft): Record<string, unknown> {
if (a.actionType === "MoveToOu") {
return { targetOu: a.targetOu, createOuIfMissing: a.createIfMissing };
}
return {
targetGroupDn: a.targetGroupDn,
syncMode: a.actionType === "SyncGroupMembership" ? a.syncMode : undefined,
createIfMissing: a.createIfMissing,
groupScope: a.groupScope,
groupType: a.groupType,
};
}
@@ -1,202 +0,0 @@
"use client";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import Chip from "@mui/material/Chip";
import CircularProgress from "@mui/material/CircularProgress";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import FormControlLabel from "@mui/material/FormControlLabel";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { useEffect, useState } from "react";
import { ApiError } from "@/lib/api/client";
import { ConnectionsApi, RulesApi, SchedulesApi } from "@/lib/api/resources";
import type { ADConnection, Rule, Schedule } from "@/lib/api/types";
interface Props {
open: boolean;
ruleId: string | null;
onClose: () => void;
onSaved: () => void | Promise<void>;
}
const OBJECT_TYPES = ["User", "Computer", "Group"];
const EXECUTION_MODES = [
{ value: "Apply", label: "Apply (execute actions)" },
{ value: "PreviewOnly", label: "Preview only (dry-run)" },
];
const JOIN_OPERATORS = ["AND", "OR"];
const SEARCH_SCOPES = ["", "Base", "OneLevel", "Subtree"];
export default function RuleFormDialog({ open, ruleId, onClose, onSaved }: Props) {
const [rule, setRule] = useState<Rule | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<Partial<Rule>>({});
const [connections, setConnections] = useState<ADConnection[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
useEffect(() => {
if (!open) return;
setError(null);
setRule(null);
setForm({
name: "",
description: "",
isEnabled: true,
adConnectionId: "",
objectType: "User",
baseDnOverride: "",
searchScopeOverride: "",
scheduleId: "",
executionMode: "Apply",
groupJoinOperator: "AND",
maxParallelism: 1,
stopOnError: false,
});
Promise.all([
ConnectionsApi.list({ pageSize: 200 }).then((r) => setConnections(r.items)).catch(() => setConnections([])),
SchedulesApi.list({ pageSize: 200 }).then((r) => setSchedules(r.items)).catch(() => setSchedules([])),
]);
if (ruleId) {
setLoading(true);
RulesApi.get(ruleId)
.then((r) => { setRule(r); setForm(r); })
.catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load rule"))
.finally(() => setLoading(false));
}
}, [open, ruleId]);
const set = <K extends keyof Rule>(key: K, value: Rule[K] | undefined) =>
setForm((f) => ({ ...f, [key]: value }));
const handleSubmit = async () => {
if (!form.name?.trim()) return setError("Name is required");
if (!form.adConnectionId) return setError("AD connection is required");
if (!form.objectType) return setError("Object type is required");
setSaving(true);
setError(null);
try {
const body: Partial<Rule> = {
...form,
name: form.name.trim(),
description: form.description?.trim() || undefined,
baseDnOverride: form.baseDnOverride?.trim() || undefined,
searchScopeOverride: form.searchScopeOverride || undefined,
scheduleId: form.scheduleId || undefined,
};
if (ruleId) await RulesApi.update(ruleId, body);
else await RulesApi.create(body);
await onSaved();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Failed to save rule");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{ruleId ? "Edit Rule" : "New Rule"}</DialogTitle>
<DialogContent>
{loading ? (
<Stack alignItems="center" py={4}><CircularProgress /></Stack>
) : (
<Stack spacing={2} sx={{ mt: 1 }}>
{error && <Alert severity="error">{error}</Alert>}
<Typography variant="subtitle2">Identity</Typography>
<TextField label="Name" value={form.name ?? ""} onChange={(e) => set("name", e.target.value)} required fullWidth />
<TextField label="Description" value={form.description ?? ""} onChange={(e) => set("description", e.target.value)} fullWidth multiline rows={2} />
<FormControlLabel control={<Switch checked={Boolean(form.isEnabled)} onChange={(_, v) => set("isEnabled", v)} />} label="Enabled" />
<Divider />
<Typography variant="subtitle2">Target Directory</Typography>
<Stack direction="row" spacing={2}>
<TextField select label="AD Connection" value={form.adConnectionId ?? ""} onChange={(e) => set("adConnectionId", e.target.value)} fullWidth required>
{connections.map((c) => <MenuItem key={c.id} value={c.id}>{c.name}</MenuItem>)}
</TextField>
<TextField select label="Object Type" value={form.objectType ?? "User"} onChange={(e) => set("objectType", e.target.value)} fullWidth required>
{OBJECT_TYPES.map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
</TextField>
</Stack>
<TextField label="Base DN Override" value={form.baseDnOverride ?? ""} onChange={(e) => set("baseDnOverride", e.target.value)} fullWidth placeholder="Leave blank to use connection root" />
<TextField select label="Search Scope Override" value={form.searchScopeOverride ?? ""} onChange={(e) => set("searchScopeOverride", e.target.value)} fullWidth>
{SEARCH_SCOPES.map((s) => <MenuItem key={s || "default"} value={s}>{s || "(use connection default)"}</MenuItem>)}
</TextField>
<Divider />
<Typography variant="subtitle2">Execution</Typography>
<Stack direction="row" spacing={2}>
<TextField select label="Execution Mode" value={form.executionMode ?? "Apply"} onChange={(e) => set("executionMode", e.target.value)} fullWidth>
{EXECUTION_MODES.map((m) => <MenuItem key={m.value} value={m.value}>{m.label}</MenuItem>)}
</TextField>
<TextField select label="Schedule" value={form.scheduleId ?? ""} onChange={(e) => set("scheduleId", e.target.value)} fullWidth>
<MenuItem value=""><em>Manual only</em></MenuItem>
{schedules.map((s) => <MenuItem key={s.id} value={s.id}>{s.name}</MenuItem>)}
</TextField>
</Stack>
<Stack direction="row" spacing={2} alignItems="center">
<TextField select label="Group Join Operator" value={form.groupJoinOperator ?? "AND"} onChange={(e) => set("groupJoinOperator", e.target.value)} fullWidth>
{JOIN_OPERATORS.map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
</TextField>
<TextField label="Max Parallelism" type="number" value={form.maxParallelism ?? ""} onChange={(e) => set("maxParallelism", e.target.value === "" ? undefined : Number(e.target.value))} fullWidth />
<FormControlLabel control={<Switch checked={Boolean(form.stopOnError)} onChange={(_, v) => set("stopOnError", v)} />} label="Stop on error" />
</Stack>
{rule && (
<>
<Divider />
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="subtitle2">Conditions &amp; Actions</Typography>
<Typography variant="caption" color="textSecondary">Read-only author via config import</Typography>
</Stack>
<Stack spacing={1}>
<Typography variant="caption" color="textSecondary">
{rule.conditionGroups.length} condition group{rule.conditionGroups.length === 1 ? "" : "s"}
</Typography>
{rule.conditionGroups.map((g, gi) => (
<Stack key={g.id} spacing={0.5} sx={{ pl: 1, borderLeft: "2px solid", borderColor: "divider" }}>
<Typography variant="body2" fontWeight={600}>
Group {gi + 1}{g.name ? `: ${g.name}` : ""} ({g.joinOperator}{g.negate ? " / NOT" : ""})
</Typography>
{g.conditions.map((c) => (
<Typography key={c.id} variant="caption" sx={{ fontFamily: "monospace" }}>
{c.negate ? "NOT " : ""}{c.attributeName} {c.operator}{" "}
{c.operator === "CustomLdap" ? (c.customLdapExpression ?? "") : (c.comparisonValue ?? "")}
</Typography>
))}
</Stack>
))}
<Typography variant="caption" color="textSecondary" sx={{ mt: 1 }}>
{rule.actions.length} action{rule.actions.length === 1 ? "" : "s"}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{rule.actions.map((a) => (
<Chip key={a.id} size="small" label={a.actionType} variant="outlined" />
))}
</Stack>
</Stack>
</>
)}
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained" disabled={saving || loading}>
{saving ? "Saving..." : ruleId ? "Save" : "Create"}
</Button>
</DialogActions>
</Dialog>
);
}

Some files were not shown because too many files have changed in this diff Show More