Dynamic groups, activity intelligence, API keys, Swagger, maintenance #11
@@ -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
@@ -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
|
||||
|
||||
@@ -21,6 +21,20 @@ All configuration is managed through the UI or API — **no manual file editing
|
||||
|
||||
---
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
| Dashboard | Rules |
|
||||
| --- | --- |
|
||||
| [](docs/screenshots/dashboard.png) | [](docs/screenshots/rules.png) |
|
||||
|
||||
| Credentials | Sign in |
|
||||
| --- | --- |
|
||||
| [](docs/screenshots/credentials.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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}…</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>
|
||||
|
||||
@@ -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 & 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
Reference in New Issue
Block a user