diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index cf6652a..f44f8f7 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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" ` diff --git a/Dockerfile b/Dockerfile index f8eabc6..08da293 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 587d8ba..d5f9638 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,20 @@ All configuration is managed through the UI or API โ€” **no manual file editing --- +## ๐Ÿ“ธ Screenshots + +| Dashboard | Rules | +| --- | --- | +| [![Dashboard](docs/screenshots/dashboard.png)](docs/screenshots/dashboard.png) | [![Rules](docs/screenshots/rules.png)](docs/screenshots/rules.png) | + +| Credentials | Sign in | +| --- | --- | +| [![Credentials](docs/screenshots/credentials.png)](docs/screenshots/credentials.png) | [![Sign in](docs/screenshots/login.png)](docs/screenshots/login.png) | + +The entire UI is served by the single backend binary (embedded Next.js static export) โ€” no separate web server to run. + +--- + ## โœจ Key Features ### ๐Ÿ” Rule-Based Automation @@ -113,14 +127,83 @@ OrchestrAD separates authentication concerns: --- -## ๐Ÿงพ Logging Format +## ๐Ÿ”Œ API & Automation -Standard logs: +Everything the UI does is available over the REST API, so rules can be created and run programmatically. + +* **Interactive docs (Swagger UI):** `https://:18090/api/docs` +* **OpenAPI 3 spec:** `https://: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 `. + +### 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', '') +$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--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 (`โ€ฆ--.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 + + 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 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///`. --- ## ๐Ÿณ 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. --- diff --git a/backend/go.mod b/backend/go.mod index e7ea6cd..c5e8487 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 59b5cc5..52dfed8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/adtest/ad_test.go b/backend/internal/adtest/ad_test.go new file mode 100644 index 0000000..5dfe7eb --- /dev/null +++ b/backend/internal/adtest/ad_test.go @@ -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) + } +} diff --git a/backend/internal/api/activity_handlers.go b/backend/internal/api/activity_handlers.go new file mode 100644 index 0000000..a1f52e2 --- /dev/null +++ b/backend/internal/api/activity_handlers.go @@ -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) +} diff --git a/backend/internal/api/apikeys_handlers.go b/backend/internal/api/apikeys_handlers.go index 86bbd23..7d8dcf9 100644 --- a/backend/internal/api/apikeys_handlers.go +++ b/backend/internal/api/apikeys_handlers.go @@ -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, diff --git a/backend/internal/api/audit_helpers.go b/backend/internal/api/audit_helpers.go index 346f04b..1e8b5d3 100644 --- a/backend/internal/api/audit_helpers.go +++ b/backend/internal/api/audit_helpers.go @@ -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 +} diff --git a/backend/internal/api/config_handlers.go b/backend/internal/api/config_handlers.go index a1925ce..843f4ec 100644 --- a/backend/internal/api/config_handlers.go +++ b/backend/internal/api/config_handlers.go @@ -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": , "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) diff --git a/backend/internal/api/connections_handlers.go b/backend/internal/api/connections_handlers.go index 628a8f7..42ae37d 100644 --- a/backend/internal/api/connections_handlers.go +++ b/backend/internal/api/connections_handlers.go @@ -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 { diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index 8b72c2f..18916fb 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -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) }) diff --git a/backend/internal/api/oidc_handlers.go b/backend/internal/api/oidc_handlers.go new file mode 100644 index 0000000..f2abae9 --- /dev/null +++ b/backend/internal/api/oidc_handlers.go @@ -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" +} diff --git a/backend/internal/api/openapi.go b/backend/internal/api/openapi.go new file mode 100644 index 0000000..1c90808 --- /dev/null +++ b/backend/internal/api/openapi.go @@ -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 `.", + }, + "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 = ` + + + + + OrchestrAD API + + + +
+ + + +` diff --git a/backend/internal/api/openapi_test.go b/backend/internal/api/openapi_test.go new file mode 100644 index 0000000..3370125 --- /dev/null +++ b/backend/internal/api/openapi_test.go @@ -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 " /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) + } + } +} diff --git a/backend/internal/api/rules_handlers.go b/backend/internal/api/rules_handlers.go index a11710f..338cf9e 100644 --- a/backend/internal/api/rules_handlers.go +++ b/backend/internal/api/rules_handlers.go @@ -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)) diff --git a/backend/internal/api/rules_metadata.go b/backend/internal/api/rules_metadata.go new file mode 100644 index 0000000..4c05030 --- /dev/null +++ b/backend/internal/api/rules_metadata.go @@ -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)"}, + }, + }, + } +} diff --git a/backend/internal/api/tls_handlers.go b/backend/internal/api/tls_handlers.go new file mode 100644 index 0000000..98c1bfe --- /dev/null +++ b/backend/internal/api/tls_handlers.go @@ -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") +} diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index c226ec1..0af787d 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -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. diff --git a/backend/internal/auth/oidc_test.go b/backend/internal/auth/oidc_test.go new file mode 100644 index 0000000..951465f --- /dev/null +++ b/backend/internal/auth/oidc_test.go @@ -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) + } +} diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index d820a01..c9f7b83 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -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 + // /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) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9681d6b..a836ac8 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index b979dee..08c6421 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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) diff --git a/backend/internal/config/registry_other.go b/backend/internal/config/registry_other.go new file mode 100644 index 0000000..a0a0f1c --- /dev/null +++ b/backend/internal/config/registry_other.go @@ -0,0 +1,6 @@ +//go:build !windows + +package config + +// registrySetting is a no-op off Windows. +func registrySetting(string) string { return "" } diff --git a/backend/internal/config/registry_windows.go b/backend/internal/config/registry_windows.go new file mode 100644 index 0000000..3421bfe --- /dev/null +++ b/backend/internal/config/registry_windows.go @@ -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 +} diff --git a/backend/internal/db/migrations/005_managed_group_members.down.sql b/backend/internal/db/migrations/005_managed_group_members.down.sql new file mode 100644 index 0000000..7fbbf0f --- /dev/null +++ b/backend/internal/db/migrations/005_managed_group_members.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_managed_group_members_rule_group; +DROP TABLE IF EXISTS managed_group_members; diff --git a/backend/internal/db/migrations/005_managed_group_members.up.sql b/backend/internal/db/migrations/005_managed_group_members.up.sql new file mode 100644 index 0000000..69441c4 --- /dev/null +++ b/backend/internal/db/migrations/005_managed_group_members.up.sql @@ -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); diff --git a/backend/internal/db/migrations/006_api_key_scope.down.sql b/backend/internal/db/migrations/006_api_key_scope.down.sql new file mode 100644 index 0000000..9909434 --- /dev/null +++ b/backend/internal/db/migrations/006_api_key_scope.down.sql @@ -0,0 +1 @@ +ALTER TABLE api_keys DROP COLUMN scope; diff --git a/backend/internal/db/migrations/006_api_key_scope.up.sql b/backend/internal/db/migrations/006_api_key_scope.up.sql new file mode 100644 index 0000000..c6ce284 --- /dev/null +++ b/backend/internal/db/migrations/006_api_key_scope.up.sql @@ -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'; diff --git a/backend/internal/directory/ldap/canonical.go b/backend/internal/directory/ldap/canonical.go new file mode 100644 index 0000000..32f2dc9 --- /dev/null +++ b/backend/internal/directory/ldap/canonical.go @@ -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 +} diff --git a/backend/internal/directory/ldap/canonical_test.go b/backend/internal/directory/ldap/canonical_test.go new file mode 100644 index 0000000..0e66480 --- /dev/null +++ b/backend/internal/directory/ldap/canonical_test.go @@ -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) + } +} diff --git a/backend/internal/directory/ldap/client.go b/backend/internal/directory/ldap/client.go index da6ba71..d1617e5 100644 --- a/backend/internal/directory/ldap/client.go +++ b/backend/internal/directory/ldap/client.go @@ -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) diff --git a/backend/internal/directory/ldap/filters.go b/backend/internal/directory/ldap/filters.go index fa9ce3e..64a789e 100644 --- a/backend/internal/directory/ldap/filters.go +++ b/backend/internal/directory/ldap/filters.go @@ -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 { diff --git a/backend/internal/directory/ldap/operations.go b/backend/internal/directory/ldap/operations.go index 52d3d4e..e43f260 100644 --- a/backend/internal/directory/ldap/operations.go +++ b/backend/internal/directory/ldap/operations.go @@ -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"}) diff --git a/backend/internal/models/rule.go b/backend/internal/models/rule.go index 54f6ba3..fcbfec3 100644 --- a/backend/internal/models/rule.go +++ b/backend/internal/models/rule.go @@ -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"` diff --git a/backend/internal/pki/export.go b/backend/internal/pki/export.go new file mode 100644 index 0000000..11d59aa --- /dev/null +++ b/backend/internal/pki/export.go @@ -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) +} diff --git a/backend/internal/pki/pki.go b/backend/internal/pki/pki.go new file mode 100644 index 0000000..a4a272f --- /dev/null +++ b/backend/internal/pki/pki.go @@ -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 +} diff --git a/backend/internal/pki/pki_test.go b/backend/internal/pki/pki_test.go new file mode 100644 index 0000000..a6ecbf8 --- /dev/null +++ b/backend/internal/pki/pki_test.go @@ -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") + } +} diff --git a/backend/internal/repository/connections.go b/backend/internal/repository/connections.go index 2782793..ba6ee6f 100644 --- a/backend/internal/repository/connections.go +++ b/backend/internal/repository/connections.go @@ -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) } diff --git a/backend/internal/repository/credentials.go b/backend/internal/repository/credentials.go index 53ca449..3495c76 100644 --- a/backend/internal/repository/credentials.go +++ b/backend/internal/repository/credentials.go @@ -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) } diff --git a/backend/internal/repository/managed_members.go b/backend/internal/repository/managed_members.go new file mode 100644 index 0000000..95b611b --- /dev/null +++ b/backend/internal/repository/managed_members.go @@ -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 +} diff --git a/backend/internal/repository/rule_runs.go b/backend/internal/repository/rule_runs.go index 09904f3..ff8f053 100644 --- a/backend/internal/repository/rule_runs.go +++ b/backend/internal/repository/rule_runs.go @@ -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) } diff --git a/backend/internal/repository/rules.go b/backend/internal/repository/rules.go index f9fb4cc..fbd13d4 100644 --- a/backend/internal/repository/rules.go +++ b/backend/internal/repository/rules.go @@ -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) } diff --git a/backend/internal/repository/rules_logic.go b/backend/internal/repository/rules_logic.go new file mode 100644 index 0000000..b1eabb9 --- /dev/null +++ b/backend/internal/repository/rules_logic.go @@ -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() +} diff --git a/backend/internal/repository/schedules.go b/backend/internal/repository/schedules.go index 3c08adf..0099526 100644 --- a/backend/internal/repository/schedules.go +++ b/backend/internal/repository/schedules.go @@ -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) } diff --git a/backend/internal/repository/timestamps_test.go b/backend/internal/repository/timestamps_test.go new file mode 100644 index 0000000..fa068f7 --- /dev/null +++ b/backend/internal/repository/timestamps_test.go @@ -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) + } +} diff --git a/backend/internal/repository/users.go b/backend/internal/repository/users.go index 457c070..0436909 100644 --- a/backend/internal/repository/users.go +++ b/backend/internal/repository/users.go @@ -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) { diff --git a/backend/internal/rules/engine/actions.go b/backend/internal/rules/engine/actions.go index da7b065..2facdb5 100644 --- a/backend/internal/rules/engine/actions.go +++ b/backend/internal/rules/engine/actions.go @@ -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. diff --git a/backend/internal/rules/engine/engine.go b/backend/internal/rules/engine/engine.go index 85d88f4..5a17e14 100644 --- a/backend/internal/rules/engine/engine.go +++ b/backend/internal/rules/engine/engine.go @@ -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 { diff --git a/backend/internal/rules/engine/reconcile.go b/backend/internal/rules/engine/reconcile.go new file mode 100644 index 0000000..480c631 --- /dev/null +++ b/backend/internal/rules/engine/reconcile.go @@ -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 +} diff --git a/backend/internal/rules/engine/reconcile_test.go b/backend/internal/rules/engine/reconcile_test.go new file mode 100644 index 0000000..72623fb --- /dev/null +++ b/backend/internal/rules/engine/reconcile_test.go @@ -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) + } +} diff --git a/backend/internal/rules/runner/runner.go b/backend/internal/rules/runner/runner.go index 0090d04..6bf87c8 100644 --- a/backend/internal/rules/runner/runner.go +++ b/backend/internal/rules/runner/runner.go @@ -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) + } } diff --git a/backend/internal/server/proxy.go b/backend/internal/server/proxy.go index 289a5d9..3d48b56 100644 --- a/backend/internal/server/proxy.go +++ b/backend/internal/server/proxy.go @@ -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 diff --git a/backend/internal/server/proxy_test.go b/backend/internal/server/proxy_test.go new file mode 100644 index 0000000..6bc561f --- /dev/null +++ b/backend/internal/server/proxy_test.go @@ -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") + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 81f4f69..d106acb 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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 } }() diff --git a/backend/internal/services/activity_service.go b/backend/internal/services/activity_service.go new file mode 100644 index 0000000..e8a4908 --- /dev/null +++ b/backend/internal/services/activity_service.go @@ -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 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 } diff --git a/backend/internal/services/activity_service_test.go b/backend/internal/services/activity_service_test.go new file mode 100644 index 0000000..cc826de --- /dev/null +++ b/backend/internal/services/activity_service_test.go @@ -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{} +} diff --git a/backend/internal/services/apikey_service.go b/backend/internal/services/apikey_service.go index 2ce935f..26353a5 100644 --- a/backend/internal/services/apikey_service.go +++ b/backend/internal/services/apikey_service.go @@ -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 } diff --git a/backend/internal/services/connection_service.go b/backend/internal/services/connection_service.go index fda53cd..5db72e2 100644 --- a/backend/internal/services/connection_service.go +++ b/backend/internal/services/connection_service.go @@ -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": diff --git a/backend/internal/services/maintenance_service.go b/backend/internal/services/maintenance_service.go new file mode 100644 index 0000000..5119240 --- /dev/null +++ b/backend/internal/services/maintenance_service.go @@ -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 +} diff --git a/backend/internal/services/maintenance_service_test.go b/backend/internal/services/maintenance_service_test.go new file mode 100644 index 0000000..1b0b800 --- /dev/null +++ b/backend/internal/services/maintenance_service_test.go @@ -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") + } +} diff --git a/backend/internal/services/rule_service.go b/backend/internal/services/rule_service.go index 218957d..764403c 100644 --- a/backend/internal/services/rule_service.go +++ b/backend/internal/services/rule_service.go @@ -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 diff --git a/backend/internal/services/schedule_seed.go b/backend/internal/services/schedule_seed.go new file mode 100644 index 0000000..407f743 --- /dev/null +++ b/backend/internal/services/schedule_seed.go @@ -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) + } +} diff --git a/backend/internal/services/settings_resolver.go b/backend/internal/services/settings_resolver.go new file mode 100644 index 0000000..87c5b72 --- /dev/null +++ b/backend/internal/services/settings_resolver.go @@ -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 +} diff --git a/backend/internal/services/settings_resolver_test.go b/backend/internal/services/settings_resolver_test.go new file mode 100644 index 0000000..013cf60 --- /dev/null +++ b/backend/internal/services/settings_resolver_test.go @@ -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) + } +} diff --git a/backend/internal/tlsmgr/manager.go b/backend/internal/tlsmgr/manager.go new file mode 100644 index 0000000..c1fbdff --- /dev/null +++ b/backend/internal/tlsmgr/manager.go @@ -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 /tls +// provided an administrator-supplied cert/key (bring-your-own), stored +// under /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 /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 /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.), 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 +// /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 +} diff --git a/backend/internal/tlsmgr/manager_test.go b/backend/internal/tlsmgr/manager_test.go new file mode 100644 index 0000000..788be81 --- /dev/null +++ b/backend/internal/tlsmgr/manager_test.go @@ -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) + } +} diff --git a/backend/internal/tlsmgr/store.go b/backend/internal/tlsmgr/store.go new file mode 100644 index 0000000..d12ba3e --- /dev/null +++ b/backend/internal/tlsmgr/store.go @@ -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"` +} diff --git a/backend/internal/tlsmgr/store_other.go b/backend/internal/tlsmgr/store_other.go new file mode 100644 index 0000000..61a3616 --- /dev/null +++ b/backend/internal/tlsmgr/store_other.go @@ -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 } diff --git a/backend/internal/tlsmgr/store_windows.go b/backend/internal/tlsmgr/store_windows.go new file mode 100644 index 0000000..331b579 --- /dev/null +++ b/backend/internal/tlsmgr/store_windows.go @@ -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) +} diff --git a/backend/internal/tlsmgr/suffixes_other.go b/backend/internal/tlsmgr/suffixes_other.go new file mode 100644 index 0000000..3a17e10 --- /dev/null +++ b/backend/internal/tlsmgr/suffixes_other.go @@ -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 +} diff --git a/backend/internal/tlsmgr/suffixes_windows.go b/backend/internal/tlsmgr/suffixes_windows.go new file mode 100644 index 0000000..5bc9855 --- /dev/null +++ b/backend/internal/tlsmgr/suffixes_windows.go @@ -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 +} diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 306ac30..888476b 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 12c70c6..d1b336b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/examples/Create-OrchestrADRule.ps1 b/docs/examples/Create-OrchestrADRule.ps1 new file mode 100644 index 0000000..104f77c --- /dev/null +++ b/docs/examples/Create-OrchestrADRule.ps1 @@ -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) diff --git a/docs/screenshots/credentials.png b/docs/screenshots/credentials.png new file mode 100644 index 0000000..e99169b Binary files /dev/null and b/docs/screenshots/credentials.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..2f312ef Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/login.png b/docs/screenshots/login.png new file mode 100644 index 0000000..b57d04d Binary files /dev/null and b/docs/screenshots/login.png differ diff --git a/docs/screenshots/rules.png b/docs/screenshots/rules.png new file mode 100644 index 0000000..7389ce5 Binary files /dev/null and b/docs/screenshots/rules.png differ diff --git a/docs/screenshots/schedules.png b/docs/screenshots/schedules.png new file mode 100644 index 0000000..41195c9 Binary files /dev/null and b/docs/screenshots/schedules.png differ diff --git a/frontend/public/images/logos/orchestrad.svg b/frontend/public/images/logos/orchestrad.svg new file mode 100644 index 0000000..5403910 --- /dev/null +++ b/frontend/public/images/logos/orchestrad.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/(app)/activity/ActivityDetailDialog.tsx b/frontend/src/app/(app)/activity/ActivityDetailDialog.tsx new file mode 100644 index 0000000..c85520d --- /dev/null +++ b/frontend/src/app/(app)/activity/ActivityDetailDialog.tsx @@ -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 ( + + + {label} + + + {value} + + + ); +} + +export default function ActivityDetailDialog({ + record, + onClose, +}: { + record: ActivityRecord | null; + onClose: () => void; +}) { + const details = prettyJson(record?.detailsJson); + return ( + + {record && ( + <> + + + {record.actionType} + + + + + + + + + + + + + + + {record.errorMessage && ( + <> + + Error + + {record.errorMessage} + + + )} + + {details && ( + <> + + Details + + {details} + + + )} + + + + )} + + ); +} diff --git a/frontend/src/app/(app)/activity/categories.ts b/frontend/src/app/(app)/activity/categories.ts new file mode 100644 index 0000000..de2cfd0 --- /dev/null +++ b/frontend/src/app/(app)/activity/categories.ts @@ -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; + } +} diff --git a/frontend/src/app/(app)/activity/page.tsx b/frontend/src/app/(app)/activity/page.tsx new file mode 100644 index 0000000..1db2f36 --- /dev/null +++ b/frontend/src/app/(app)/activity/page.tsx @@ -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) => ( + + {value} + {label} + + ); + return ( + + + {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")} + + + ); +} + +export default function ActivityPage() { + const [summary, setSummary] = useState(null); + const [items, setItems] = useState([]); + 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(null); + const [detail, setDetail] = useState(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 ( + + + {/* Summary windows */} + + {(summary?.windows ?? []).map((w) => ( + + + + ))} + + {/* By action type roll-up */} + + + {summary && summary.byActionType.length > 0 ? ( + + + + + Action + Category + Total + Ok + Failed + + + + {summary.byActionType.map((a) => ( + + {a.actionType} + + + + {a.total} + {a.success} + + {a.failed > 0 ? {a.failed} : 0} + + + ))} + +
+
+ ) : ( + No activity recorded yet. + )} +
+
+ + {/* Most active rules */} + + + {summary && summary.topRules.length > 0 ? ( + + + + + Rule + Actions + Failed + + + + {summary.topRules.map((r) => ( + + {r.ruleName} + {r.total} + + {r.failed > 0 ? {r.failed} : 0} + + + ))} + +
+
+ ) : ( + No activity recorded yet. + )} +
+
+
+ + {/* Drill-in feed */} + + + + + + } + > + + {error && ( + setError(null)}>{error} + )} + + + { setCategory(e.target.value); setPage(0); }} + > + All categories + {CATEGORIES.map((c) => {categoryLabel(c)})} + + { setStatus(e.target.value); setPage(0); }} + > + {STATUS_OPTIONS.map((s) => ( + {s || "All results"} + ))} + + { setSearch(e.target.value); setPage(0); }} + placeholder="CN=โ€ฆ,OU=โ€ฆ" + /> + + + {loading && items.length === 0 ? ( + + ) : items.length === 0 ? ( + No activity matches the current filters. + ) : ( + + + + + When + Category + Action + Object + Rule + Result + By + Details + + + + {items.map((a) => ( + + {formatDateTime(a.createdUtc)} + + + + {a.actionType} + + {a.objectDn} + + {a.ruleName} + + + + {a.triggeredBy ?? "โ€”"} + + + setDetail(a)}> + + + + + + ))} + +
+ setPage(p)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(0); }} + rowsPerPageOptions={[10, 25, 50, 100]} + /> +
+ )} +
+
+
+ + setDetail(null)} /> +
+ ); +} diff --git a/frontend/src/app/(app)/api-keys/ApiKeyCreateDialog.tsx b/frontend/src/app/(app)/api-keys/ApiKeyCreateDialog.tsx index 67c45ba..4c9de53 100644 --- a/frontend/src/app/(app)/api-keys/ApiKeyCreateDialog.tsx +++ b/frontend/src/app/(app)/api-keys/ApiKeyCreateDialog.tsx @@ -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(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" /> + setScope(e.target.value)} fullWidth + helperText="Read = GET only. Read/Write = full access." + > + Read / Write (full access) + Read only + setExpiresAt(e.target.value)} diff --git a/frontend/src/app/(app)/api-keys/page.tsx b/frontend/src/app/(app)/api-keys/page.tsx index d9ff04e..6dc964d 100644 --- a/frontend/src/app/(app)/api-keys/page.tsx +++ b/frontend/src/app/(app)/api-keys/page.tsx @@ -141,6 +141,7 @@ export default function ApiKeysPage() { Name Prefix + Scope Status Enabled Created @@ -164,6 +165,11 @@ export default function ApiKeysPage() { {k.keyPrefix}… + + + {revoked ? : expired ? diff --git a/frontend/src/app/(app)/components/dashboard/CountTile.tsx b/frontend/src/app/(app)/components/dashboard/CountTile.tsx index 8577ace..120a06a 100644 --- a/frontend/src/app/(app)/components/dashboard/CountTile.tsx +++ b/frontend/src/app/(app)/components/dashboard/CountTile.tsx @@ -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 = ( - + {label} @@ -41,6 +46,13 @@ const CountTile = ({ label, total, enabled, color = "primary" }: Props) => { ); + + if (!href) return body; + return ( + + {body} + + ); }; export default CountTile; diff --git a/frontend/src/app/(app)/layout/horizontal/header/Header.tsx b/frontend/src/app/(app)/layout/horizontal/header/Header.tsx index f21d51d..e182c6b 100644 --- a/frontend/src/app/(app)/layout/horizontal/header/Header.tsx +++ b/frontend/src/app/(app)/layout/horizontal/header/Header.tsx @@ -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 */} {/* ------------------------------------------- */} - {activeMode === 'light' ? ( setActiveMode("dark")} /> diff --git a/frontend/src/app/(app)/layout/horizontal/navbar/Menudata.ts b/frontend/src/app/(app)/layout/horizontal/navbar/Menudata.ts index 0342f26..0b3d219 100644 --- a/frontend/src/app/(app)/layout/horizontal/navbar/Menudata.ts +++ b/frontend/src/app/(app)/layout/horizontal/navbar/Menudata.ts @@ -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' }, ], }, { diff --git a/frontend/src/app/(app)/layout/shared/logo/Logo.tsx b/frontend/src/app/(app)/layout/shared/logo/Logo.tsx index 4fd0357..0ab07ad 100644 --- a/frontend/src/app/(app)/layout/shared/logo/Logo.tsx +++ b/frontend/src/app/(app)/layout/shared/logo/Logo.tsx @@ -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 ( - - {activeMode === "dark" ? ( - logo - ) : ( - logo - )} - - ); - } - return ( - {activeMode === "dark" ? ( - logo - ) : ( - logo - )} + + {!compact ? ( + + OrchestrAD + + ) : null} ); } diff --git a/frontend/src/app/(app)/layout/vertical/header/Header.tsx b/frontend/src/app/(app)/layout/vertical/header/Header.tsx index 3c7b310..d63a893 100644 --- a/frontend/src/app/(app)/layout/vertical/header/Header.tsx +++ b/frontend/src/app/(app)/layout/vertical/header/Header.tsx @@ -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 = () => { {smUp ? : ""} - {activeMode === 'light' ? ( setActiveMode("dark")} /> diff --git a/frontend/src/app/(app)/layout/vertical/header/Language.tsx b/frontend/src/app/(app)/layout/vertical/header/Language.tsx deleted file mode 100644 index 766cbcb..0000000 --- a/frontend/src/app/(app)/layout/vertical/header/Language.tsx +++ /dev/null @@ -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(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) => { - setAnchorEl(event.currentTarget); - }; - const handleClose = () => { - setAnchorEl(null); - }; - useEffect(() => { - i18n.changeLanguage(isLanguage); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ( - <> - - - {Languages.map((option, index) => ( - setIsLanguage(option.value)} - > - - - {option.flagname} - - - ))} - - - ); -}; - -export default Language; diff --git a/frontend/src/app/(app)/layout/vertical/header/Search.tsx b/frontend/src/app/(app)/layout/vertical/header/Search.tsx index 6cee562..b7cf943 100644 --- a/frontend/src/app/(app)/layout/vertical/header/Search.tsx +++ b/frontend/src/app/(app)/layout/vertical/header/Search.tsx @@ -70,7 +70,7 @@ const Search = () => { borderRadius: "25px", }} > - Try to searching + Search 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() { {isCollapse == "mini-sidebar" ? null : ( - + - - - - Mike - Admin + + + {initials(session?.user?.displayName || session?.user?.username)} + + + + {session?.user?.displayName || session?.user?.username || "Signed in"} + + + {session?.user?.roles?.[0] || "User"} + - - + logout()} aria-label="Sign out"> + diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 7d19832..bea552f 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -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(null); const [error, setError] = useState(null); @@ -74,72 +77,80 @@ export default function Dashboard() { - + - + - + - + - + - + - - - - {ruleRunStats.last24hTotal} - Total - - - {ruleRunStats.last24hSuccess} - Success - - - {ruleRunStats.last24hFailed} - Failed - - - + + + + + {ruleRunStats.last24hTotal} + Total + + + {ruleRunStats.last24hSuccess} + Success + + + {ruleRunStats.last24hFailed} + Failed + + + + - - - - {ruleRunStats.last7dTotal} - Total - - - {ruleRunStats.last7dSuccess} - Success - - - {ruleRunStats.last7dFailed} - Failed - - - + + + + + {ruleRunStats.last7dTotal} + Total + + + {ruleRunStats.last7dSuccess} + Success + + + {ruleRunStats.last7dFailed} + Failed + + + + - - - + + + + + - - - + + + + + diff --git a/frontend/src/app/(app)/rules/AttributePicker.tsx b/frontend/src/app/(app)/rules/AttributePicker.tsx new file mode 100644 index 0000000..4d1f3d5 --- /dev/null +++ b/frontend/src/app/(app)/rules/AttributePicker.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const timer = useRef | 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(); + 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 ( + + 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 ( +
  • +
    + {name} + {desc && {desc}} +
    +
  • + ); + }} + renderInput={(params) => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ); +} diff --git a/frontend/src/app/(app)/rules/DirectoryPicker.tsx b/frontend/src/app/(app)/rules/DirectoryPicker.tsx new file mode 100644 index 0000000..852fa82 --- /dev/null +++ b/frontend/src/app/(app)/rules/DirectoryPicker.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const timer = useRef | 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(); + options.forEach((o) => m.set(o.dn, o)); + return m; + }, [options]); + + return ( + + 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 ( +
  • +
    + {obj?.name ?? (typeof o === "string" ? o : o.name)} + + {obj?.canonicalName ?? (typeof o === "string" ? o : o.dn)} + +
    +
  • + ); + }} + renderInput={(params) => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ); +} diff --git a/frontend/src/app/(app)/rules/RuleEditorDialog.tsx b/frontend/src/app/(app)/rules/RuleEditorDialog.tsx new file mode 100644 index 0000000..dfc3fe0 --- /dev/null +++ b/frontend/src/app/(app)/rules/RuleEditorDialog.tsx @@ -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; +} + +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(null); + + const [meta, setMeta] = useState(null); + const [connections, setConnections] = useState([]); + const [schedules, setSchedules] = useState([]); + + // 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([newGroup()]); + const [actions, setActions] = useState([newAction()]); + + // Schedule + const [scheduleMode, setScheduleMode] = useState("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(null); + const [previewing, setPreviewing] = useState(false); + const [previewError, setPreviewError] = useState(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 = {}; + 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 => { + if (scheduleMode === "manual") return undefined; + if (scheduleMode === "existing") return scheduleId || undefined; + // create a schedule then return its id + const body: Partial = + 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) => + setGroups((gs) => gs.map((g, i) => (i === gi ? { ...g, ...patch } : g))); + const setCond = (gi: number, ci: number, patch: Partial) => + 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) => + setActions((as) => as.map((a, i) => (i === ai ? { ...a, ...patch } : a))); + + return ( + + {ruleId ? "Edit dynamic group rule" : "New dynamic group rule"} + + {loading ? ( + + ) : ( + <> + {error && setError(null)}>{error}} + setTab(v)} sx={{ mb: 2 }} variant="scrollable" scrollButtons="auto"> + + + + + + + + {/* ---- Scope ---- */} + {tab === 0 && ( + + setName(e.target.value)} required fullWidth /> + setDescription(e.target.value)} fullWidth multiline rows={2} /> + + setAdConnectionId(e.target.value)} fullWidth required> + {connections.map((c) => {c.name})} + + setObjectType(e.target.value)} fullWidth> + {(meta?.objectTypes ?? [{ value: "User", label: "Users" }]).map((o) => {o.label})} + + + + + setSearchScopeOverride(e.target.value)} fullWidth> + (connection default) + {(meta?.searchScopes ?? []).map((s) => {s.label})} + + setExecutionMode(e.target.value)} fullWidth> + Apply (make changes) + Preview only (dry-run) + + + + setIsEnabled(v)} />} label="Enabled" /> + setStopOnError(v)} />} label="Stop on first error" /> + + + )} + + {/* ---- Filter ---- */} + {tab === 1 && ( + + + Select the objects to sync. Objects match when they satisfy the condition groups below. + + {groups.length > 1 && ( + setGroupJoinOperator(e.target.value)} sx={{ maxWidth: 260 }}> + {(meta?.joinOperators ?? [{ value: "AND", label: "Match ALL of" }, { value: "OR", label: "Match ANY of" }]).map((o) => + {o.label})} + + )} + {groups.map((g, gi) => ( + + + Group {gi + 1} + 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) => + {o.label})} + + setGroup(gi, { negate: v })} />} label="NOT" /> + + {groups.length > 1 && ( + setGroups((gs) => gs.filter((_, i) => i !== gi))}> + )} + + + {g.conditions.map((c, ci) => ( + + setCond(gi, ci, { attributeName: v })} + /> + setCond(gi, ci, { operator: e.target.value })} sx={{ minWidth: 200 }}> + {operators.map((o) => {o.label})} + + {opNeedsValue(c.operator) && ( + (c.operator === "MemberOf" || c.operator === "MemberOfRecursive") ? ( + + setCond(gi, ci, { value: dn })} /> + + ) : opIsCustom(c.operator) ? ( + setCond(gi, ci, { value: e.target.value })} sx={{ flex: 1, minWidth: 180 }} + placeholder={operators.find((o) => o.value === c.operator)?.hint} /> + ) : ( + setCond(gi, ci, { value: v })} + /> + ) + )} + setCond(gi, ci, { negate: v })} />} label="NOT" /> + + + setGroup(gi, { conditions: g.conditions.filter((_, j) => j !== ci) })}> + + + + + + ))} + + + + ))} + + + )} + + {/* ---- Target & action ---- */} + {tab === 2 && ( + + What to do with the matched objects. + {actions.map((a, ai) => ( + + + setAction(ai, { actionType: e.target.value })} sx={{ minWidth: 280 }}> + {(meta?.actionTypes ?? [{ value: "SyncGroupMembership", label: "Sync membership to group" }]).map((o) => + {o.label})} + + + {meta?.actionTypes?.find((t) => t.value === a.actionType)?.description} + + {actions.length > 1 && ( + setActions((as) => as.filter((_, i) => i !== ai))}> + )} + + + {(a.actionType === "SyncGroupMembership" || a.actionType === "AddToGroup" || a.actionType === "EnsureGroupExists") && ( + + setAction(ai, { targetGroupDn: dn })} + helperText="Search, or paste a group DN / canonical path." /> + {a.actionType === "SyncGroupMembership" && ( + 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) => + {m.label})} + + )} + + setAction(ai, { createIfMissing: v })} />} label="Create group if missing" /> + {a.createIfMissing && ( + <> + setAction(ai, { groupScope: e.target.value })} sx={{ minWidth: 140 }}> + {["Global", "DomainLocal", "Universal"].map((s) => {s})} + + setAction(ai, { groupType: e.target.value })} sx={{ minWidth: 150 }}> + {["Security", "Distribution"].map((s) => {s})} + + + )} + + + )} + + {a.actionType === "MoveToOu" && ( + + setAction(ai, { targetOu: dn })} + helperText="Move each matched object into this OU." /> + setAction(ai, { createIfMissing: v })} />} label="Create OU path if missing" /> + + )} + + ))} + + + )} + + {/* ---- Schedule ---- */} + {tab === 3 && ( + + setScheduleMode(e.target.value as ScheduleMode)}> + Manually only + On an existing schedule + On an interval (create) + On a cron expression (create) + + {scheduleMode === "existing" && ( + setScheduleId(e.target.value)}> + Selectโ€ฆ + {schedules.map((s) => {s.name})} + + )} + {scheduleMode === "interval" && ( + + Every + setIntervalValue(Math.max(1, Number(e.target.value)))} sx={{ width: 100 }} /> + setIntervalUnit(e.target.value)} sx={{ minWidth: 140 }}> + {(meta?.scheduleUnits ?? [{ value: "Hours", label: "hours" }]).map((u) => {u.label})} + + + )} + {scheduleMode === "cron" && ( + setCronExpression(e.target.value)} + helperText="6-field (with seconds), UTC. e.g. '0 0 * * * *' = top of every hour." /> + )} + + )} + + {/* ---- Preview ---- */} + {tab === 4 && ( + + + + Dry-run against the directory โ€” makes no changes. + + {previewError && {previewError}} + {preview && ( + <> + + {preview.matchedObjects.length} object(s) match. + {preview.generatedFilter} + + {(preview.warnings ?? []).map((w, i) => {w})} + + Planned changes + + {preview.plannedActions + .filter((p) => p.actionType === "SyncGroupMembership") + .map((p, i) => ( + {p.description} + ))} + {preview.plannedActions.length === 0 && No changes.} + + + Sample matches + + {preview.matchedObjects.slice(0, 20).map((m) => ( + + {m.canonicalName || m.dn} + + ))} + {preview.matchedObjects.length > 20 && ( + โ€ฆand {preview.matchedObjects.length - 20} more + )} + + + )} + + )} + + )} + + + + n + g.conditions.length, 0)} condition(s) ยท ${actions.length} action(s)`} /> + + + + + + ); +} + +function actionConfig(a: ActionDraft): Record { + 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, + }; +} diff --git a/frontend/src/app/(app)/rules/RuleFormDialog.tsx b/frontend/src/app/(app)/rules/RuleFormDialog.tsx deleted file mode 100644 index 0755c36..0000000 --- a/frontend/src/app/(app)/rules/RuleFormDialog.tsx +++ /dev/null @@ -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; -} - -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(null); - const [loading, setLoading] = useState(false); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [form, setForm] = useState>({}); - const [connections, setConnections] = useState([]); - const [schedules, setSchedules] = useState([]); - - 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 = (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 = { - ...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 ( - - {ruleId ? "Edit Rule" : "New Rule"} - - {loading ? ( - - ) : ( - - {error && {error}} - - Identity - set("name", e.target.value)} required fullWidth /> - set("description", e.target.value)} fullWidth multiline rows={2} /> - set("isEnabled", v)} />} label="Enabled" /> - - - Target Directory - - set("adConnectionId", e.target.value)} fullWidth required> - {connections.map((c) => {c.name})} - - set("objectType", e.target.value)} fullWidth required> - {OBJECT_TYPES.map((o) => {o})} - - - set("baseDnOverride", e.target.value)} fullWidth placeholder="Leave blank to use connection root" /> - set("searchScopeOverride", e.target.value)} fullWidth> - {SEARCH_SCOPES.map((s) => {s || "(use connection default)"})} - - - - Execution - - set("executionMode", e.target.value)} fullWidth> - {EXECUTION_MODES.map((m) => {m.label})} - - set("scheduleId", e.target.value)} fullWidth> - Manual only - {schedules.map((s) => {s.name})} - - - - set("groupJoinOperator", e.target.value)} fullWidth> - {JOIN_OPERATORS.map((o) => {o})} - - set("maxParallelism", e.target.value === "" ? undefined : Number(e.target.value))} fullWidth /> - set("stopOnError", v)} />} label="Stop on error" /> - - - {rule && ( - <> - - - Conditions & Actions - Read-only โ€” author via config import - - - - {rule.conditionGroups.length} condition group{rule.conditionGroups.length === 1 ? "" : "s"} - - {rule.conditionGroups.map((g, gi) => ( - - - Group {gi + 1}{g.name ? `: ${g.name}` : ""} ({g.joinOperator}{g.negate ? " / NOT" : ""}) - - {g.conditions.map((c) => ( - - {c.negate ? "NOT " : ""}{c.attributeName} {c.operator}{" "} - {c.operator === "CustomLdap" ? (c.customLdapExpression ?? "") : (c.comparisonValue ?? "")} - - ))} - - ))} - - {rule.actions.length} action{rule.actions.length === 1 ? "" : "s"} - - - {rule.actions.map((a) => ( - - ))} - - - - )} - - )} - - - - - - - ); -} diff --git a/frontend/src/app/(app)/rules/RuleHistoryDialog.tsx b/frontend/src/app/(app)/rules/RuleHistoryDialog.tsx new file mode 100644 index 0000000..ba53758 --- /dev/null +++ b/frontend/src/app/(app)/rules/RuleHistoryDialog.tsx @@ -0,0 +1,179 @@ +"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 Dialog from "@mui/material/Dialog"; +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 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 { ApiError } from "@/lib/api/client"; +import { ActivityApi } from "@/lib/api/resources"; +import type { ActivityRecord } from "@/lib/api/types"; +import { formatDateTime, runStatusColor } from "@/lib/format"; + +import ActivityDetailDialog from "../activity/ActivityDetailDialog"; +import { CATEGORIES, categoryColor, categoryLabel } from "../activity/categories"; + +interface Props { + ruleId: string | null; + ruleName?: string; + onClose: () => void; +} + +const STATUS_OPTIONS = ["", "Succeeded", "Failed"]; + +// RuleHistoryDialog shows the per-object action history for a single rule โ€” +// the actual objects added, removed, moved, etc. โ€” with filtering and paging. +// It reuses the activity feed scoped by ruleId. +export default function RuleHistoryDialog({ ruleId, ruleName, onClose }: Props) { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(25); + const [category, setCategory] = useState(""); + const [status, setStatus] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [detail, setDetail] = useState(null); + + const load = useCallback(async () => { + if (!ruleId) return; + setLoading(true); + setError(null); + try { + const res = await ActivityApi.list({ + ruleId, + page: page + 1, + pageSize, + category: category || undefined, + status: status || undefined, + }); + setItems(res.items); + setTotal(Number(res.meta?.totalCount ?? res.items.length)); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to load history"); + } finally { + setLoading(false); + } + }, [ruleId, page, pageSize, category, status]); + + useEffect(() => { + if (ruleId) void load(); + }, [ruleId, load]); + + // Reset paging/filters whenever a different rule is opened. + useEffect(() => { + setPage(0); + setCategory(""); + setStatus(""); + }, [ruleId]); + + return ( + + + + History{ruleName ? ` โ€” ${ruleName}` : ""} + + + + void load()} disabled={loading}> + + + + + + + + {error && setError(null)}>{error}} + + + { setCategory(e.target.value); setPage(0); }} sx={{ minWidth: 160 }}> + All categories + {CATEGORIES.map((c) => {categoryLabel(c)})} + + { setStatus(e.target.value); setPage(0); }} sx={{ minWidth: 150 }}> + {STATUS_OPTIONS.map((s) => {s || "All results"})} + + + + {loading && items.length === 0 ? ( + + ) : items.length === 0 ? ( + No actions recorded for this rule yet. + ) : ( + + + + + When + Category + Action + Object + Result + By + Details + + + + {items.map((a) => ( + + {formatDateTime(a.createdUtc)} + + + + {a.actionType} + + {a.objectDn} + + + + + {a.triggeredBy ?? "โ€”"} + + + setDetail(a)}> + + + + + + ))} + +
    + setPage(p)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { setPageSize(parseInt(e.target.value, 10)); setPage(0); }} + rowsPerPageOptions={[10, 25, 50, 100]} + /> +
    + )} +
    + + setDetail(null)} /> +
    + ); +} diff --git a/frontend/src/app/(app)/rules/RulePreviewDialog.tsx b/frontend/src/app/(app)/rules/RulePreviewDialog.tsx index 1b42390..fa8fb4e 100644 --- a/frontend/src/app/(app)/rules/RulePreviewDialog.tsx +++ b/frontend/src/app/(app)/rules/RulePreviewDialog.tsx @@ -111,6 +111,7 @@ export default function RulePreviewDialog({ rule, onClose }: Props) { + Canonical Name DN Type @@ -118,6 +119,7 @@ export default function RulePreviewDialog({ rule, onClose }: Props) { {result.matchedObjects.slice(0, 100).map((m, i) => ( + {m.canonicalName || "โ€”"} {m.dn} {m.objectType} diff --git a/frontend/src/app/(app)/rules/ValuePicker.tsx b/frontend/src/app/(app)/rules/ValuePicker.tsx new file mode 100644 index 0000000..ffccaac --- /dev/null +++ b/frontend/src/app/(app)/rules/ValuePicker.tsx @@ -0,0 +1,84 @@ +"use client"; + +import Autocomplete from "@mui/material/Autocomplete"; +import CircularProgress from "@mui/material/CircularProgress"; +import TextField from "@mui/material/TextField"; +import { useEffect, useRef, useState } from "react"; + +import { ConnectionsApi } from "@/lib/api/resources"; + +interface Props { + connectionId: string; + objectType: string; + attribute: string; + baseDn?: string; + value: string; + onChange: (value: string) => void; + label?: string; + placeholder?: string; +} + +// ValuePicker suggests the distinct values actually present in the directory +// for the chosen attribute (sampled), while still allowing any typed value. +export default function ValuePicker({ connectionId, objectType, attribute, baseDn, value, onChange, label, placeholder }: Props) { + const [input, setInput] = useState(""); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const timer = useRef | null>(null); + + const canQuery = Boolean(connectionId && attribute.trim()); + + useEffect(() => { + if (!canQuery || !open) return; + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => { + setLoading(true); + ConnectionsApi.attributeValues(connectionId, { attribute: attribute.trim(), objectType, q: input, baseDn, limit: 50 }) + .then(setOptions) + .catch(() => setOptions([])) + .finally(() => setLoading(false)); + }, 350); + return () => { + if (timer.current) clearTimeout(timer.current); + }; + }, [canQuery, open, input, attribute, objectType, baseDn, connectionId]); + + return ( + + freeSolo + size="small" + sx={{ flex: 1, minWidth: 180 }} + options={options} + loading={loading} + open={open} + onOpen={() => setOpen(true)} + onClose={() => setOpen(false)} + filterOptions={(x) => x} + value={value} + onChange={(_, v) => onChange(v ?? "")} + onInputChange={(_, v, reason) => { + if (reason === "input") { + setInput(v); + onChange(v); + } + }} + renderInput={(params) => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ); +} diff --git a/frontend/src/app/(app)/rules/page.tsx b/frontend/src/app/(app)/rules/page.tsx index 86c34fc..5ac9833 100644 --- a/frontend/src/app/(app)/rules/page.tsx +++ b/frontend/src/app/(app)/rules/page.tsx @@ -18,6 +18,7 @@ import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; +import HistoryOutlinedIcon from "@mui/icons-material/HistoryOutlined"; import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; import { useCallback, useEffect, useState } from "react"; @@ -25,12 +26,13 @@ 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 { RulesApi } from "@/lib/api/resources"; -import type { RuleSummary } from "@/lib/api/types"; +import { RulesApi, SchedulesApi } from "@/lib/api/resources"; +import type { RuleSummary, Schedule } from "@/lib/api/types"; import { formatDateTime, testResultColor } from "@/lib/format"; -import RuleFormDialog from "./RuleFormDialog"; +import RuleEditorDialog from "./RuleEditorDialog"; import RulePreviewDialog from "./RulePreviewDialog"; +import RuleHistoryDialog from "./RuleHistoryDialog"; export default function RulesPage() { const [items, setItems] = useState([]); @@ -40,14 +42,20 @@ export default function RulesPage() { const [formOpen, setFormOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [previewing, setPreviewing] = useState(null); + const [historyRule, setHistoryRule] = useState(null); const [running, setRunning] = useState(null); + const [schedulesById, setSchedulesById] = useState>(new Map()); const load = useCallback(async () => { setLoading(true); setError(null); try { - const res = await RulesApi.list({ pageSize: 100 }); + const [res, sched] = await Promise.all([ + RulesApi.list({ pageSize: 100 }), + SchedulesApi.list({ pageSize: 200 }).catch(() => ({ items: [] as Schedule[] })), + ]); setItems(res.items); + setSchedulesById(new Map(sched.items.map((s) => [s.id, s]))); } catch (err) { setError(err instanceof ApiError ? err.message : "Failed to load rules"); } finally { @@ -128,6 +136,7 @@ export default function RulesPage() { Conditions Actions Enabled + Schedule Last Run Last Result Actions @@ -150,6 +159,20 @@ export default function RulesPage() { handleToggle(r)} /> + + {r.scheduleId && schedulesById.get(r.scheduleId) ? ( + + {schedulesById.get(r.scheduleId)!.name} + {schedulesById.get(r.scheduleId)!.nextRunUtc && ( + + next {formatDateTime(schedulesById.get(r.scheduleId)!.nextRunUtc)} + + )} + + ) : ( + Manual + )} + {formatDateTime(r.lastRunUtc)} {r.lastRunResult ? ( @@ -162,6 +185,11 @@ export default function RulesPage() { + + setHistoryRule(r)}> + + + handleRun(r)}> @@ -189,7 +217,7 @@ export default function RulesPage() { - setFormOpen(false)} @@ -197,6 +225,12 @@ export default function RulesPage() { /> setPreviewing(null)} /> + + setHistoryRule(null)} + /> ); } diff --git a/frontend/src/app/(app)/security/page.tsx b/frontend/src/app/(app)/security/page.tsx new file mode 100644 index 0000000..6632e11 --- /dev/null +++ b/frontend/src/app/(app)/security/page.tsx @@ -0,0 +1,378 @@ +"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 Divider from "@mui/material/Divider"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Grid from "@mui/material/Grid"; +import Radio from "@mui/material/Radio"; +import RadioGroup from "@mui/material/RadioGroup"; +import Stack from "@mui/material/Stack"; +import Switch from "@mui/material/Switch"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +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 { OidcApi, TlsApi } from "@/lib/api/resources"; +import type { OidcConfig, TlsStatus, WindowsStoreCert } from "@/lib/api/types"; +import { formatDateTime } from "@/lib/format"; + +export default function SecurityPage() { + return ( + + + + + + + ); +} + +/* ------------------------------- OIDC / SSO ------------------------------- */ + +const emptyOidc: OidcConfig = { + enabled: false, + issuer: "", + clientId: "", + clientSecret: "", + redirectUrl: "", + scopes: "openid profile email", + usernameClaim: "preferred_username", + emailClaim: "email", + nameClaim: "name", + defaultRole: "", +}; + +function OidcCard() { + const [cfg, setCfg] = useState(emptyOidc); + const [secretDirty, setSecretDirty] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setCfg(await OidcApi.getConfig()); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to load SSO configuration"); + } finally { + setLoading(false); + } + }, []); + useEffect(() => { + load(); + }, [load]); + + const set = (patch: Partial) => { + setSaved(false); + setCfg((c) => ({ ...c, ...patch })); + }; + + const save = async () => { + setSaving(true); + setError(null); + setSaved(false); + try { + const body: Partial = { + enabled: cfg.enabled, + issuer: cfg.issuer, + clientId: cfg.clientId, + redirectUrl: cfg.redirectUrl, + scopes: cfg.scopes, + usernameClaim: cfg.usernameClaim, + emailClaim: cfg.emailClaim, + nameClaim: cfg.nameClaim, + defaultRole: cfg.defaultRole, + }; + // Only send the secret when the operator actually typed a new one. + if (secretDirty) body.clientSecret = cfg.clientSecret; + const next = await OidcApi.putConfig(body); + setCfg(next); + setSecretDirty(false); + setSaved(true); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to save SSO configuration"); + } finally { + setSaving(false); + } + }; + + return ( + + {loading ? ( + + + + ) : ( + + + Configure an OpenID Connect provider (Entra ID, Okta, Keycloak, โ€ฆ). Values set here + override the corresponding environment variables. + + {error ? {error} : null} + {saved ? SSO configuration saved. : null} + + set({ enabled: e.target.checked })} />} + label="Enable SSO" + /> + + + set({ issuer: e.target.value })} /> + + + set({ defaultRole: e.target.value })} /> + + + set({ clientId: e.target.value })} /> + + + { setSecretDirty(true); set({ clientSecret: e.target.value }); }} /> + + + set({ redirectUrl: e.target.value })} /> + + + set({ scopes: e.target.value })} /> + + + set({ usernameClaim: e.target.value })} /> + + + set({ emailClaim: e.target.value })} /> + + + set({ nameClaim: e.target.value })} /> + + + + + + + )} + + ); +} + +/* ---------------------------------- TLS ---------------------------------- */ + +function TlsCard() { + const [status, setStatus] = useState(null); + const [mode, setMode] = useState("auto"); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [msg, setMsg] = useState(null); + + // Bring-your-own inputs. + const [certPem, setCertPem] = useState(""); + const [keyPem, setKeyPem] = useState(""); + const [chainPem, setChainPem] = useState(""); + + // Windows store. + const [winCerts, setWinCerts] = useState(null); + const [winErr, setWinErr] = useState(null); + const [thumb, setThumb] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const s = await TlsApi.status(); + setStatus(s); + if (s.certificate?.mode) setMode(s.certificate.mode); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to load TLS status"); + } finally { + setLoading(false); + } + }, []); + useEffect(() => { + load(); + }, [load]); + + const apply = async (fn: () => Promise, okMsg: string) => { + setBusy(true); + setError(null); + setMsg(null); + try { + await fn(); + setMsg(okMsg); + await load(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "TLS operation failed"); + } finally { + setBusy(false); + } + }; + + const loadWinCerts = async () => { + setWinErr(null); + setWinCerts(null); + try { + setWinCerts(await TlsApi.windowsCerts()); + } catch (err) { + setWinErr(err instanceof ApiError ? err.message : "Could not enumerate the Windows store"); + } + }; + + return ( + + {loading ? ( + + + + ) : !status?.enabled ? ( + + TLS is disabled โ€” the server is serving plain HTTP (TLS is terminated upstream). Enable + TLS to manage a certificate here. + + ) : ( + + {status.certificate ? ( + + + + {status.certificate.fallback ? ( + + ) : null} + + + + Subject: {status.certificate.subject || "โ€”"} + + + Issuer: {status.certificate.issuer || "โ€”"} + + + ) : null} + + {error ? {error} : null} + {msg ? {msg} : null} + + + Certificate source + setMode(e.target.value)}> + } label="Self-managed CA (auto-generated & renewed)" /> + } label="Bring your own certificate (upload)" /> + } + label="Windows certificate store" + disabled={!status.windowsStoreSupported} /> + + + {mode === "auto" ? ( + + + + ) : null} + + {mode === "provided" ? ( + + setCertPem(e.target.value)} + slotProps={{ htmlInput: { style: { fontFamily: "monospace", fontSize: 12 } } }} /> + setKeyPem(e.target.value)} + slotProps={{ htmlInput: { style: { fontFamily: "monospace", fontSize: 12 } } }} /> + setChainPem(e.target.value)} + slotProps={{ htmlInput: { style: { fontFamily: "monospace", fontSize: 12 } } }} /> + + + + + ) : null} + + {mode === "windows-store" ? ( + + + + + {winErr ? {winErr} : null} + {winCerts && winCerts.length > 0 ? ( + +
    + + + + Subject + Issuer + Expires + Key + + + + {winCerts.map((c) => ( + setThumb(c.thumbprint)} sx={{ cursor: "pointer" }}> + + + + {c.subject} + {c.issuer} + {formatDateTime(c.notAfter)} + + {c.hasPrivateKey ? + : } + + + ))} + +
    +
    + ) : winCerts ? ( + No certificates found. + ) : null} + + + + + ) : null} + + )} + + ); +} diff --git a/frontend/src/app/context/AuthContext.tsx b/frontend/src/app/context/AuthContext.tsx index aaf8688..35e8aed 100644 --- a/frontend/src/app/context/AuthContext.tsx +++ b/frontend/src/app/context/AuthContext.tsx @@ -28,6 +28,7 @@ interface AuthContextValue { ready: boolean; session: StoredSession | null; login: (username: string, password: string) => Promise; + adoptSession: (token: string, expiresAt: string) => Promise; logout: () => Promise; updateSessionUser: (patch: Partial) => void; } @@ -38,6 +39,9 @@ export const AuthContext = createContext({ login: async () => { throw new Error("AuthContext not mounted"); }, + adoptSession: async () => { + throw new Error("AuthContext not mounted"); + }, logout: async () => {}, updateSessionUser: () => {}, }); @@ -126,6 +130,18 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { return result; }, []); + // adoptSession accepts a session token minted elsewhere (the OIDC/SSO + // callback hands it back via the URL fragment), stores it so the API client + // authenticates, hydrates the user via /auth/me, and returns it. + const adoptSession = useCallback(async (token: string, expiresAt: string) => { + writeStoredSession({ token, expiresAt, user: {} as UserInfo }); + const user = await AuthApi.me(); + const stored: StoredSession = { token, expiresAt, user }; + writeStoredSession(stored); + setSession(stored); + return user; + }, []); + const updateSessionUser = useCallback((patch: Partial) => { setSession((prev) => { if (!prev) return prev; @@ -139,8 +155,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }, []); const value = useMemo( - () => ({ ready, session, login, logout, updateSessionUser }), - [ready, session, login, logout, updateSessionUser], + () => ({ ready, session, login, adoptSession, logout, updateSessionUser }), + [ready, session, login, adoptSession, logout, updateSessionUser], ); return {children}; diff --git a/frontend/src/app/context/config.ts b/frontend/src/app/context/config.ts index 0ec4779..2b1f94f 100644 --- a/frontend/src/app/context/config.ts +++ b/frontend/src/app/context/config.ts @@ -1,6 +1,6 @@ const config = { activeDir: "ltr", // This can be ltr or rtl - activeMode: "light", // This can be light or dark + activeMode: "dark", // This can be light or dark (OrchestrAD defaults to dark) activeTheme: "BLUE_THEME", // BLUE_THEME, GREEN_THEME, AQUA_THEME, PURPLE_THEME, ORANGE_THEME activeLayout: "vertical", // This can be vertical or horizontal isLayout: "boxed", // This can be full or boxed diff --git a/frontend/src/app/login/AuthLogin.tsx b/frontend/src/app/login/AuthLogin.tsx index 7320c8b..89de7b6 100644 --- a/frontend/src/app/login/AuthLogin.tsx +++ b/frontend/src/app/login/AuthLogin.tsx @@ -3,10 +3,11 @@ import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import { useRouter, useSearchParams } from "next/navigation"; -import { useContext, useState, ReactNode } from "react"; +import { useContext, useEffect, useState, ReactNode } from "react"; import CustomTextField from "@/app/components/forms/theme-elements/CustomTextField"; import CustomFormLabel from "@/app/components/forms/theme-elements/CustomFormLabel"; import { AuthContext, isApiError } from "@/app/context/AuthContext"; @@ -21,11 +22,52 @@ interface loginType { const AuthLogin = ({ title, subtitle, subtext }: loginType) => { const router = useRouter(); const params = useSearchParams(); - const { login } = useContext(AuthContext); + const { login, adoptSession } = useContext(AuthContext); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [ssoEnabled, setSsoEnabled] = useState(false); + + // Is SSO configured? Controls whether the "Sign in with SSO" button shows. + useEffect(() => { + let active = true; + fetch("/api/v1/auth/oidc/status") + .then((r) => (r.ok ? r.json() : null)) + .then((body) => { + if (active) setSsoEnabled(Boolean(body?.data?.enabled ?? body?.enabled)); + }) + .catch(() => {}); + return () => { + active = false; + }; + }, []); + + // The OIDC callback redirects back to /login with the session token in the URL + // fragment. Adopt it, then continue into the app. + useEffect(() => { + if (typeof window === "undefined" || !window.location.hash) return; + const frag = new URLSearchParams(window.location.hash.replace(/^#/, "")); + const oidcError = frag.get("oidc_error"); + if (oidcError) { + history.replaceState(null, "", window.location.pathname + window.location.search); + setError(`SSO sign-in failed (${oidcError}).`); + return; + } + const token = frag.get("oidc_token"); + const expiresAt = frag.get("expires_at"); + if (!token || !expiresAt) return; + history.replaceState(null, "", window.location.pathname + window.location.search); + setSubmitting(true); + adoptSession(token, expiresAt) + .then((user) => { + router.replace(user.passwordResetRequired ? "/change-password" : safeRedirectTarget(params?.get("redirect"))); + }) + .catch(() => { + setSubmitting(false); + setError("SSO sign-in failed while establishing the session."); + }); + }, [adoptSession, router, params]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -110,6 +152,29 @@ const AuthLogin = ({ title, subtitle, subtext }: loginType) => { {submitting ? "Signing in" : "Sign In"} + + {ssoEnabled ? ( + + + + or + + + + + ) : null} + {subtitle} ); diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 118b0ba..6fae570 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,157 +1,56 @@ "use client"; -import Avatar from '@mui/material/Avatar'; -import Box from '@mui/material/Box'; -import Grid from '@mui/material/Grid'; -import { Theme } from '@mui/material/styles'; -import Typography from '@mui/material/Typography'; -import useMediaQuery from '@mui/material/useMediaQuery'; +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; import PageContainer from "@/app/components/container/PageContainer"; import Logo from "@/app/(app)/layout/shared/logo/Logo"; import AuthLogin from "./AuthLogin"; -import { CustomizerContext } from "@/app/context/customizerContext"; -import { useContext } from "react"; +// A deliberately calm, professional sign-in: a single centered card on a soft +// themed backdrop, no decorative illustrations. Works in light and dark. export default function Login() { - const lgUp = useMediaQuery((theme: Theme) => theme.breakpoints.up("lg")); - const { isBorderRadius } = useContext(CustomizerContext); - - return ( - - + theme.palette.mode === "dark" + ? `radial-gradient(1200px 600px at 50% -10%, ${theme.palette.primary.dark}22, transparent 60%), ${theme.palette.background.default}` + : `radial-gradient(1200px 600px at 50% -10%, ${theme.palette.primary.light}33, transparent 60%), ${theme.palette.background.default}`, + }} + > + theme.palette.grey[200], - overflow: "hidden", - borderRadius: isBorderRadius / 18, - display: "flex", - alignItems: "center", - justifyContent: "center", + width: "100%", + maxWidth: 420, + p: { xs: 3, sm: 5 }, + borderRadius: 3, + border: (theme) => `1px solid ${theme.palette.divider}`, + boxShadow: (theme) => + theme.palette.mode === "dark" + ? "0 24px 60px -20px rgba(0,0,0,0.6)" + : "0 24px 60px -24px rgba(37,83,185,0.25)", }} > - - theme.palette.mode === "light" ? "white" : "#111c2d", - maxWidth: { - xs: "340px", - sm: "500px", - lg: "1320px", - }, - }} - > - - - - {lgUp ? ( - - - - ) : ( - "" - )} - - - - Active Directory automation platform - - } - /> - - - - - - - + + + + Sign in to OrchestrAD + + + Active Directory rule automation + + + + + ); diff --git a/frontend/src/lib/api/resources.ts b/frontend/src/lib/api/resources.ts index 6cc9d6b..93f2f61 100644 --- a/frontend/src/lib/api/resources.ts +++ b/frontend/src/lib/api/resources.ts @@ -12,16 +12,27 @@ import { Credential, CredentialTestResult, DashboardSummary, + ActivitySummary, + ActivityRecord, + ActivityFilter, ImportReport, LoginResponse, Rule, + RuleInput, + RuleMetadata, RulePreviewResult, RuleRun, RuleRunDetail, RuleSummary, + DirectoryObject, + AttributeInfo, Schedule, User, UserInfo, + OidcConfig, + TlsStatus, + TlsInfo, + WindowsStoreCert, } from "./types"; interface PageParams { @@ -81,6 +92,21 @@ export const ConnectionsApi = { api.post(`/api/v1/ad-connections/${id}/test`).then((r) => r.data), queryPreview: (id: string, body: Record) => api.post(`/api/v1/ad-connections/${id}/query-preview`, body).then((r) => r.data), + directory: (id: string, type: string, q: string, limit = 25) => + api + .get(`/api/v1/ad-connections/${id}/directory`, { query: { type, q, limit } }) + .then((r) => r.data ?? []), + attributes: (id: string, objectType: string, q: string, limit = 50) => + api + .get(`/api/v1/ad-connections/${id}/attributes`, { query: { objectType, q, limit } }) + .then((r) => r.data ?? []), + attributeValues: ( + id: string, + params: { attribute: string; objectType?: string; q?: string; baseDn?: string; limit?: number }, + ) => + api + .get(`/api/v1/ad-connections/${id}/attribute-values`, { query: params as Record }) + .then((r) => r.data ?? []), enable: (id: string) => api.post<{ enabled: boolean }>(`/api/v1/ad-connections/${id}/enable`).then((r) => r.data), disable: (id: string) => @@ -105,12 +131,15 @@ export const SchedulesApi = { export const RulesApi = { list: (params?: PageParams) => getList("/api/v1/rules", params), get: (id: string) => api.get(`/api/v1/rules/${id}`).then((r) => r.data), - create: (body: Partial) => api.post("/api/v1/rules", body).then((r) => r.data), - update: (id: string, body: Partial) => + metadata: () => api.get("/api/v1/rules/metadata").then((r) => r.data), + create: (body: RuleInput) => api.post("/api/v1/rules", body).then((r) => r.data), + update: (id: string, body: RuleInput) => api.put(`/api/v1/rules/${id}`, body).then((r) => r.data), remove: (id: string) => api.delete<{ deleted: boolean }>(`/api/v1/rules/${id}`).then((r) => r.data), preview: (id: string) => api.post(`/api/v1/rules/${id}/preview`).then((r) => r.data), + previewSpec: (body: RuleInput) => + api.post("/api/v1/rules/preview", body).then((r) => r.data), run: (id: string) => api.post<{ ruleId: string; status: string; triggeredBy: string }>(`/api/v1/rules/${id}/run`).then((r) => r.data), enable: (id: string) => @@ -126,6 +155,11 @@ export const RuleRunsApi = { get: (id: string) => api.get(`/api/v1/rule-runs/${id}`).then((r) => r.data), }; +export const ActivityApi = { + summary: () => api.get("/api/v1/activity/summary").then((r) => r.data), + list: (params?: PageParams & ActivityFilter) => getList("/api/v1/activity", params), +}; + export const UsersApi = { list: (params?: PageParams) => getList("/api/v1/users", params), get: (id: string) => api.get(`/api/v1/users/${id}`).then((r) => r.data), @@ -140,7 +174,7 @@ export const UsersApi = { export const ApiKeysApi = { list: (params?: PageParams & { userId?: string }) => api.get("/api/v1/api-keys", { query: params as Record }).then((r) => r.data ?? []), - create: (body: { name: string; userId?: string; expiresAt?: string }) => + create: (body: { name: string; userId?: string; scope?: string; expiresAt?: string }) => api.post("/api/v1/api-keys", body).then((r) => r.data), revoke: (id: string) => api.post<{ revoked: boolean }>(`/api/v1/api-keys/${id}/revoke`).then((r) => r.data), enable: (id: string) => api.post<{ enabled: boolean }>(`/api/v1/api-keys/${id}/enable`).then((r) => r.data), @@ -181,3 +215,18 @@ export const ConfigApi = { import: (payload: ConfigExport, dryRun: boolean) => api.post("/api/v1/config/import", { payload, dryRun }).then((r) => r.data), }; + +export const OidcApi = { + getConfig: () => api.get("/api/v1/auth/oidc/config").then((r) => r.data), + putConfig: (body: Partial) => + api.put("/api/v1/auth/oidc/config", body).then((r) => r.data), +}; + +export const TlsApi = { + status: () => api.get("/api/v1/tls/status").then((r) => r.data), + setMode: (mode: string, windowsThumbprint?: string) => + api.post("/api/v1/tls/mode", { mode, windowsThumbprint }).then((r) => r.data), + uploadCertificate: (body: { certificatePem: string; privateKeyPem: string; chainPem?: string }) => + api.post("/api/v1/tls/certificate", body).then((r) => r.data), + windowsCerts: () => api.get("/api/v1/tls/windows-store").then((r) => r.data), +}; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 0407f19..6d7614d 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -172,6 +172,7 @@ export interface APIKey { userId: string; name: string; keyPrefix: string; + scope: string; expiresAt?: string; isEnabled: boolean; lastUsedAt?: string; @@ -268,6 +269,64 @@ export interface DashboardSummary { generatedUtc: string; } +export interface ActivityWindow { + key: string; + label: string; + total: number; + success: number; + failed: number; + syncs: number; + operations: number; + removals: number; + objectsAffected: number; +} + +export interface ActionTypeCount { + actionType: string; + category: string; + total: number; + success: number; + failed: number; +} + +export interface RuleActivityCount { + ruleId: string; + ruleName: string; + total: number; + failed: number; +} + +export interface ActivitySummary { + windows: ActivityWindow[]; + byActionType: ActionTypeCount[]; + topRules: RuleActivityCount[]; + generatedUtc: string; +} + +export interface ActivityRecord { + id: string; + ruleRunId: string; + ruleId: string; + ruleName: string; + objectDn: string; + actionType: string; + category: string; + status: string; + detailsJson?: string; + errorMessage?: string; + durationMs?: number; + triggeredBy?: string; + createdUtc: string; +} + +export interface ActivityFilter { + category?: string; + actionType?: string; + status?: string; + ruleId?: string; + search?: string; +} + export interface CredentialTestResult { success: boolean; message: string; @@ -290,6 +349,7 @@ export interface ConnectionTestResult { export interface RulePreviewMatchedObject { dn: string; + canonicalName?: string; objectType: string; attributes?: Record; } @@ -310,6 +370,92 @@ export interface RulePreviewResult { plannedActions: RulePreviewPlannedAction[]; } +// ---- Rule editor metadata (GET /rules/metadata) ---- +export interface LabeledValue { + value: string; + label: string; + description?: string; +} + +export interface OperatorMeta { + value: string; + label: string; + needsValue: boolean; + custom?: boolean; + hint?: string; +} + +export interface AttributeMeta { + name: string; + label: string; +} + +export interface RuleMetadata { + objectTypes: LabeledValue[]; + searchScopes: LabeledValue[]; + joinOperators: LabeledValue[]; + operators: OperatorMeta[]; + actionTypes: LabeledValue[]; + syncModes: LabeledValue[]; + scheduleUnits: LabeledValue[]; + commonAttributes: Record; +} + +// A directory object returned by the target pickers. +export interface DirectoryObject { + dn: string; + name: string; + canonicalName: string; + objectType: string; +} + +// A schema attribute returned by the attribute autocomplete. +export interface AttributeInfo { + name: string; + description?: string; +} + +// The full create/update payload the editor sends (superset of Rule scalars +// plus the authored filter and actions). +export interface RuleConditionInput { + attributeName: string; + operator: string; + comparisonValue?: string; + customLdapExpression?: string; + negate?: boolean; + isEnabled?: boolean; +} + +export interface RuleConditionGroupInput { + name?: string; + joinOperator: string; + negate?: boolean; + isEnabled?: boolean; + conditions: RuleConditionInput[]; +} + +export interface RuleActionInput { + actionType: string; + configurationJson: string; + isEnabled?: boolean; +} + +export interface RuleInput { + name?: string; + description?: string; + isEnabled?: boolean; + adConnectionId?: string; + objectType?: string; + baseDnOverride?: string; + searchScopeOverride?: string; + scheduleId?: string; + executionMode?: string; + groupJoinOperator?: string; + stopOnError?: boolean; + conditionGroups?: RuleConditionGroupInput[]; + actions?: RuleActionInput[]; +} + export interface CredentialExport { id: string; name: string; @@ -433,3 +579,44 @@ export interface ImportReport { entities: ImportEntityReport[]; errors?: string[]; } + +// --- OIDC / SSO configuration --- +export interface OidcConfig { + enabled: boolean; + issuer: string; + clientId: string; + clientSecret: string; // redacted to "********" when set + redirectUrl: string; + scopes: string; + usernameClaim: string; + emailClaim: string; + nameClaim: string; + defaultRole: string; +} + +// --- TLS configuration --- +export interface TlsInfo { + mode: string; + fallback: boolean; + subject: string; + issuer: string; + dnsNames: string[] | null; + notBefore: string; + notAfter: string; +} + +export interface TlsStatus { + enabled: boolean; + windowsStoreSupported: boolean; + certificate?: TlsInfo; +} + +export interface WindowsStoreCert { + thumbprint: string; + subject: string; + issuer: string; + notBefore: string; + notAfter: string; + hasPrivateKey: boolean; + dnsNames: string[] | null; +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index f4f74bd..71f2622 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -42,7 +42,8 @@ export function runStatusColor(status: string): ChipColor { export function testResultColor(result?: string | null): ChipColor { if (!result) return "default"; const v = result.toLowerCase(); - if (v.includes("success") || v === "ok" || v === "passed") return "success"; + if (v.includes("success") || v.includes("complete") || v === "ok" || v === "passed") return "success"; if (v.includes("fail") || v.includes("error")) return "error"; + if (v.includes("cancel")) return "warning"; return "warning"; } diff --git a/installer/OrchestrAD.wxs b/installer/OrchestrAD.wxs index 2c3b3f8..b1c5e82 100644 --- a/installer/OrchestrAD.wxs +++ b/installer/OrchestrAD.wxs @@ -1,27 +1,26 @@ - + - - - + - - - + + + + + - - - + + + @@ -83,35 +74,71 @@ - - + + + - + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/installer/license.rtf b/installer/license.rtf new file mode 100644 index 0000000..abb2c18 --- /dev/null +++ b/installer/license.rtf @@ -0,0 +1,677 @@ +{ tf1ansideff0{ onttbl{ 0 Consolas;}} s16 +GNU GENERAL PUBLIC LICENSEpar + Version 3, 29 June 2007par +par + Copyright (C) 2007 Free Software Foundation, Inc. par + Everyone is permitted to copy and distribute verbatim copiespar + of this license document, but changing it is not allowed.par +par + Preamblepar +par + The GNU General Public License is a free, copyleft license forpar +software and other kinds of works.par +par + The licenses for most software and other practical works are designedpar +to take away your freedom to share and change the works. By contrast,par +the GNU General Public License is intended to guarantee your freedom topar +share and change all versions of a program--to make sure it remains freepar +software for all its users. We, the Free Software Foundation, use thepar +GNU General Public License for most of our software; it applies also topar +any other work released this way by its authors. You can apply it topar +your programs, too.par +par + When we speak of free software, we are referring to freedom, notpar +price. Our General Public Licenses are designed to make sure that youpar +have the freedom to distribute copies of free software (and charge forpar +them if you wish), that you receive source code or can get it if youpar +want it, that you can change the software or use pieces of it in newpar +free programs, and that you know you can do these things.par +par + To protect your rights, we need to prevent others from denying youpar +these rights or asking you to surrender the rights. Therefore, you havepar +certain responsibilities if you distribute copies of the software, or ifpar +you modify it: responsibilities to respect the freedom of others.par +par + For example, if you distribute copies of such a program, whetherpar +gratis or for a fee, you must pass on to the recipients the samepar +freedoms that you received. You must make sure that they, too, receivepar +or can get the source code. And you must show them these terms so theypar +know their rights.par +par + Developers that use the GNU GPL protect your rights with two steps:par +(1) assert copyright on the software, and (2) offer you this Licensepar +giving you legal permission to copy, distribute and/or modify it.par +par + For the developers' and authors' protection, the GPL clearly explainspar +that there is no warranty for this free software. For both users' andpar +authors' sake, the GPL requires that modified versions be marked aspar +changed, so that their problems will not be attributed erroneously topar +authors of previous versions.par +par + Some devices are designed to deny users access to install or runpar +modified versions of the software inside them, although the manufacturerpar +can do so. This is fundamentally incompatible with the aim ofpar +protecting users' freedom to change the software. The systematicpar +pattern of such abuse occurs in the area of products for individuals topar +use, which is precisely where it is most unacceptable. Therefore, wepar +have designed this version of the GPL to prohibit the practice for thosepar +products. If such problems arise substantially in other domains, wepar +stand ready to extend this provision to those domains in future versionspar +of the GPL, as needed to protect the freedom of users.par +par + Finally, every program is threatened constantly by software patents.par +States should not allow patents to restrict development and use ofpar +software on general-purpose computers, but in those that do, we wish topar +avoid the special danger that patents applied to a free program couldpar +make it effectively proprietary. To prevent this, the GPL assures thatpar +patents cannot be used to render the program non-free.par +par + The precise terms and conditions for copying, distribution andpar +modification follow.par +par + TERMS AND CONDITIONSpar +par + 0. Definitions.par +par + "This License" refers to version 3 of the GNU General Public License.par +par + "Copyright" also means copyright-like laws that apply to other kinds ofpar +works, such as semiconductor masks.par +par + "The Program" refers to any copyrightable work licensed under thispar +License. Each licensee is addressed as "you". "Licensees" andpar +"recipients" may be individuals or organizations.par +par + To "modify" a work means to copy from or adapt all or part of the workpar +in a fashion requiring copyright permission, other than the making of anpar +exact copy. The resulting work is called a "modified version" of thepar +earlier work or a work "based on" the earlier work.par +par + A "covered work" means either the unmodified Program or a work basedpar +on the Program.par +par + To "propagate" a work means to do anything with it that, withoutpar +permission, would make you directly or secondarily liable forpar +infringement under applicable copyright law, except executing it on apar +computer or modifying a private copy. Propagation includes copying,par +distribution (with or without modification), making available to thepar +public, and in some countries other activities as well.par +par + To "convey" a work means any kind of propagation that enables otherpar +parties to make or receive copies. Mere interaction with a user throughpar +a computer network, with no transfer of a copy, is not conveying.par +par + An interactive user interface displays "Appropriate Legal Notices"par +to the extent that it includes a convenient and prominently visiblepar +feature that (1) displays an appropriate copyright notice, and (2)par +tells the user that there is no warranty for the work (except to thepar +extent that warranties are provided), that licensees may convey thepar +work under this License, and how to view a copy of this License. Ifpar +the interface presents a list of user commands or options, such as apar +menu, a prominent item in the list meets this criterion.par +par + 1. Source Code.par +par + The "source code" for a work means the preferred form of the workpar +for making modifications to it. "Object code" means any non-sourcepar +form of a work.par +par + A "Standard Interface" means an interface that either is an officialpar +standard defined by a recognized standards body, or, in the case ofpar +interfaces specified for a particular programming language, one thatpar +is widely used among developers working in that language.par +par + The "System Libraries" of an executable work include anything, otherpar +than the work as a whole, that (a) is included in the normal form ofpar +packaging a Major Component, but which is not part of that Majorpar +Component, and (b) serves only to enable use of the work with thatpar +Major Component, or to implement a Standard Interface for which anpar +implementation is available to the public in source code form. Apar +"Major Component", in this context, means a major essential componentpar +(kernel, window system, and so on) of the specific operating systempar +(if any) on which the executable work runs, or a compiler used topar +produce the work, or an object code interpreter used to run it.par +par + The "Corresponding Source" for a work in object code form means allpar +the source code needed to generate, install, and (for an executablepar +work) run the object code and to modify the work, including scripts topar +control those activities. However, it does not include the work'spar +System Libraries, or general-purpose tools or generally available freepar +programs which are used unmodified in performing those activities butpar +which are not part of the work. For example, Corresponding Sourcepar +includes interface definition files associated with source files forpar +the work, and the source code for shared libraries and dynamicallypar +linked subprograms that the work is specifically designed to require,par +such as by intimate data communication or control flow between thosepar +subprograms and other parts of the work.par +par + The Corresponding Source need not include anything that userspar +can regenerate automatically from other parts of the Correspondingpar +Source.par +par + The Corresponding Source for a work in source code form is thatpar +same work.par +par + 2. Basic Permissions.par +par + All rights granted under this License are granted for the term ofpar +copyright on the Program, and are irrevocable provided the statedpar +conditions are met. This License explicitly affirms your unlimitedpar +permission to run the unmodified Program. The output from running apar +covered work is covered by this License only if the output, given itspar +content, constitutes a covered work. This License acknowledges yourpar +rights of fair use or other equivalent, as provided by copyright law.par +par + You may make, run and propagate covered works that you do notpar +convey, without conditions so long as your license otherwise remainspar +in force. You may convey covered works to others for the sole purposepar +of having them make modifications exclusively for you, or provide youpar +with facilities for running those works, provided that you comply withpar +the terms of this License in conveying all material for which you dopar +not control copyright. Those thus making or running the covered workspar +for you must do so exclusively on your behalf, under your directionpar +and control, on terms that prohibit them from making any copies ofpar +your copyrighted material outside their relationship with you.par +par + Conveying under any other circumstances is permitted solely underpar +the conditions stated below. Sublicensing is not allowed; section 10par +makes it unnecessary.par +par + 3. Protecting Users' Legal Rights From Anti-Circumvention Law.par +par + No covered work shall be deemed part of an effective technologicalpar +measure under any applicable law fulfilling obligations under articlepar +11 of the WIPO copyright treaty adopted on 20 December 1996, orpar +similar laws prohibiting or restricting circumvention of suchpar +measures.par +par + When you convey a covered work, you waive any legal power to forbidpar +circumvention of technological measures to the extent such circumventionpar +is effected by exercising rights under this License with respect topar +the covered work, and you disclaim any intention to limit operation orpar +modification of the work as a means of enforcing, against the work'spar +users, your or third parties' legal rights to forbid circumvention ofpar +technological measures.par +par + 4. Conveying Verbatim Copies.par +par + You may convey verbatim copies of the Program's source code as youpar +receive it, in any medium, provided that you conspicuously andpar +appropriately publish on each copy an appropriate copyright notice;par +keep intact all notices stating that this License and anypar +non-permissive terms added in accord with section 7 apply to the code;par +keep intact all notices of the absence of any warranty; and give allpar +recipients a copy of this License along with the Program.par +par + You may charge any price or no price for each copy that you convey,par +and you may offer support or warranty protection for a fee.par +par + 5. Conveying Modified Source Versions.par +par + You may convey a work based on the Program, or the modifications topar +produce it from the Program, in the form of source code under thepar +terms of section 4, provided that you also meet all of these conditions:par +par + a) The work must carry prominent notices stating that you modifiedpar + it, and giving a relevant date.par +par + b) The work must carry prominent notices stating that it ispar + released under this License and any conditions added under sectionpar + 7. This requirement modifies the requirement in section 4 topar + "keep intact all notices".par +par + c) You must license the entire work, as a whole, under thispar + License to anyone who comes into possession of a copy. Thispar + License will therefore apply, along with any applicable section 7par + additional terms, to the whole of the work, and all its parts,par + regardless of how they are packaged. This License gives nopar + permission to license the work in any other way, but it does notpar + invalidate such permission if you have separately received it.par +par + d) If the work has interactive user interfaces, each must displaypar + Appropriate Legal Notices; however, if the Program has interactivepar + interfaces that do not display Appropriate Legal Notices, yourpar + work need not make them do so.par +par + A compilation of a covered work with other separate and independentpar +works, which are not by their nature extensions of the covered work,par +and which are not combined with it such as to form a larger program,par +in or on a volume of a storage or distribution medium, is called anpar +"aggregate" if the compilation and its resulting copyright are notpar +used to limit the access or legal rights of the compilation's userspar +beyond what the individual works permit. Inclusion of a covered workpar +in an aggregate does not cause this License to apply to the otherpar +parts of the aggregate.par +par + 6. Conveying Non-Source Forms.par +par + You may convey a covered work in object code form under the termspar +of sections 4 and 5, provided that you also convey thepar +machine-readable Corresponding Source under the terms of this License,par +in one of these ways:par +par + a) Convey the object code in, or embodied in, a physical productpar + (including a physical distribution medium), accompanied by thepar + Corresponding Source fixed on a durable physical mediumpar + customarily used for software interchange.par +par + b) Convey the object code in, or embodied in, a physical productpar + (including a physical distribution medium), accompanied by apar + written offer, valid for at least three years and valid for aspar + long as you offer spare parts or customer support for that productpar + model, to give anyone who possesses the object code either (1) apar + copy of the Corresponding Source for all the software in thepar + product that is covered by this License, on a durable physicalpar + medium customarily used for software interchange, for a price nopar + more than your reasonable cost of physically performing thispar + conveying of source, or (2) access to copy thepar + Corresponding Source from a network server at no charge.par +par + c) Convey individual copies of the object code with a copy of thepar + written offer to provide the Corresponding Source. Thispar + alternative is allowed only occasionally and noncommercially, andpar + only if you received the object code with such an offer, in accordpar + with subsection 6b.par +par + d) Convey the object code by offering access from a designatedpar + place (gratis or for a charge), and offer equivalent access to thepar + Corresponding Source in the same way through the same place at nopar + further charge. You need not require recipients to copy thepar + Corresponding Source along with the object code. If the place topar + copy the object code is a network server, the Corresponding Sourcepar + may be on a different server (operated by you or a third party)par + that supports equivalent copying facilities, provided you maintainpar + clear directions next to the object code saying where to find thepar + Corresponding Source. Regardless of what server hosts thepar + Corresponding Source, you remain obligated to ensure that it ispar + available for as long as needed to satisfy these requirements.par +par + e) Convey the object code using peer-to-peer transmission, providedpar + you inform other peers where the object code and Correspondingpar + Source of the work are being offered to the general public at nopar + charge under subsection 6d.par +par + A separable portion of the object code, whose source code is excludedpar +from the Corresponding Source as a System Library, need not bepar +included in conveying the object code work.par +par + A "User Product" is either (1) a "consumer product", which means anypar +tangible personal property which is normally used for personal, family,par +or household purposes, or (2) anything designed or sold for incorporationpar +into a dwelling. In determining whether a product is a consumer product,par +doubtful cases shall be resolved in favor of coverage. For a particularpar +product received by a particular user, "normally used" refers to apar +typical or common use of that class of product, regardless of the statuspar +of the particular user or of the way in which the particular userpar +actually uses, or expects or is expected to use, the product. A productpar +is a consumer product regardless of whether the product has substantialpar +commercial, industrial or non-consumer uses, unless such uses representpar +the only significant mode of use of the product.par +par + "Installation Information" for a User Product means any methods,par +procedures, authorization keys, or other information required to installpar +and execute modified versions of a covered work in that User Product frompar +a modified version of its Corresponding Source. The information mustpar +suffice to ensure that the continued functioning of the modified objectpar +code is in no case prevented or interfered with solely becausepar +modification has been made.par +par + If you convey an object code work under this section in, or with, orpar +specifically for use in, a User Product, and the conveying occurs aspar +part of a transaction in which the right of possession and use of thepar +User Product is transferred to the recipient in perpetuity or for apar +fixed term (regardless of how the transaction is characterized), thepar +Corresponding Source conveyed under this section must be accompaniedpar +by the Installation Information. But this requirement does not applypar +if neither you nor any third party retains the ability to installpar +modified object code on the User Product (for example, the work haspar +been installed in ROM).par +par + The requirement to provide Installation Information does not include apar +requirement to continue to provide support service, warranty, or updatespar +for a work that has been modified or installed by the recipient, or forpar +the User Product in which it has been modified or installed. Access to apar +network may be denied when the modification itself materially andpar +adversely affects the operation of the network or violates the rules andpar +protocols for communication across the network.par +par + Corresponding Source conveyed, and Installation Information provided,par +in accord with this section must be in a format that is publiclypar +documented (and with an implementation available to the public inpar +source code form), and must require no special password or key forpar +unpacking, reading or copying.par +par + 7. Additional Terms.par +par + "Additional permissions" are terms that supplement the terms of thispar +License by making exceptions from one or more of its conditions.par +Additional permissions that are applicable to the entire Program shallpar +be treated as though they were included in this License, to the extentpar +that they are valid under applicable law. If additional permissionspar +apply only to part of the Program, that part may be used separatelypar +under those permissions, but the entire Program remains governed bypar +this License without regard to the additional permissions.par +par + When you convey a copy of a covered work, you may at your optionpar +remove any additional permissions from that copy, or from any part ofpar +it. (Additional permissions may be written to require their ownpar +removal in certain cases when you modify the work.) You may placepar +additional permissions on material, added by you to a covered work,par +for which you have or can give appropriate copyright permission.par +par + Notwithstanding any other provision of this License, for material youpar +add to a covered work, you may (if authorized by the copyright holders ofpar +that material) supplement the terms of this License with terms:par +par + a) Disclaiming warranty or limiting liability differently from thepar + terms of sections 15 and 16 of this License; orpar +par + b) Requiring preservation of specified reasonable legal notices orpar + author attributions in that material or in the Appropriate Legalpar + Notices displayed by works containing it; orpar +par + c) Prohibiting misrepresentation of the origin of that material, orpar + requiring that modified versions of such material be marked inpar + reasonable ways as different from the original version; orpar +par + d) Limiting the use for publicity purposes of names of licensors orpar + authors of the material; orpar +par + e) Declining to grant rights under trademark law for use of somepar + trade names, trademarks, or service marks; orpar +par + f) Requiring indemnification of licensors and authors of thatpar + material by anyone who conveys the material (or modified versions ofpar + it) with contractual assumptions of liability to the recipient, forpar + any liability that these contractual assumptions directly impose onpar + those licensors and authors.par +par + All other non-permissive additional terms are considered "furtherpar +restrictions" within the meaning of section 10. If the Program as youpar +received it, or any part of it, contains a notice stating that it ispar +governed by this License along with a term that is a furtherpar +restriction, you may remove that term. If a license document containspar +a further restriction but permits relicensing or conveying under thispar +License, you may add to a covered work material governed by the termspar +of that license document, provided that the further restriction doespar +not survive such relicensing or conveying.par +par + If you add terms to a covered work in accord with this section, youpar +must place, in the relevant source files, a statement of thepar +additional terms that apply to those files, or a notice indicatingpar +where to find the applicable terms.par +par + Additional terms, permissive or non-permissive, may be stated in thepar +form of a separately written license, or stated as exceptions;par +the above requirements apply either way.par +par + 8. Termination.par +par + You may not propagate or modify a covered work except as expresslypar +provided under this License. Any attempt otherwise to propagate orpar +modify it is void, and will automatically terminate your rights underpar +this License (including any patent licenses granted under the thirdpar +paragraph of section 11).par +par + However, if you cease all violation of this License, then yourpar +license from a particular copyright holder is reinstated (a)par +provisionally, unless and until the copyright holder explicitly andpar +finally terminates your license, and (b) permanently, if the copyrightpar +holder fails to notify you of the violation by some reasonable meanspar +prior to 60 days after the cessation.par +par + Moreover, your license from a particular copyright holder ispar +reinstated permanently if the copyright holder notifies you of thepar +violation by some reasonable means, this is the first time you havepar +received notice of violation of this License (for any work) from thatpar +copyright holder, and you cure the violation prior to 30 days afterpar +your receipt of the notice.par +par + Termination of your rights under this section does not terminate thepar +licenses of parties who have received copies or rights from you underpar +this License. If your rights have been terminated and not permanentlypar +reinstated, you do not qualify to receive new licenses for the samepar +material under section 10.par +par + 9. Acceptance Not Required for Having Copies.par +par + You are not required to accept this License in order to receive orpar +run a copy of the Program. Ancillary propagation of a covered workpar +occurring solely as a consequence of using peer-to-peer transmissionpar +to receive a copy likewise does not require acceptance. However,par +nothing other than this License grants you permission to propagate orpar +modify any covered work. These actions infringe copyright if you dopar +not accept this License. Therefore, by modifying or propagating apar +covered work, you indicate your acceptance of this License to do so.par +par + 10. Automatic Licensing of Downstream Recipients.par +par + Each time you convey a covered work, the recipient automaticallypar +receives a license from the original licensors, to run, modify andpar +propagate that work, subject to this License. You are not responsiblepar +for enforcing compliance by third parties with this License.par +par + An "entity transaction" is a transaction transferring control of anpar +organization, or substantially all assets of one, or subdividing anpar +organization, or merging organizations. If propagation of a coveredpar +work results from an entity transaction, each party to thatpar +transaction who receives a copy of the work also receives whateverpar +licenses to the work the party's predecessor in interest had or couldpar +give under the previous paragraph, plus a right to possession of thepar +Corresponding Source of the work from the predecessor in interest, ifpar +the predecessor has it or can get it with reasonable efforts.par +par + You may not impose any further restrictions on the exercise of thepar +rights granted or affirmed under this License. For example, you maypar +not impose a license fee, royalty, or other charge for exercise ofpar +rights granted under this License, and you may not initiate litigationpar +(including a cross-claim or counterclaim in a lawsuit) alleging thatpar +any patent claim is infringed by making, using, selling, offering forpar +sale, or importing the Program or any portion of it.par +par + 11. Patents.par +par + A "contributor" is a copyright holder who authorizes use under thispar +License of the Program or a work on which the Program is based. Thepar +work thus licensed is called the contributor's "contributor version".par +par + A contributor's "essential patent claims" are all patent claimspar +owned or controlled by the contributor, whether already acquired orpar +hereafter acquired, that would be infringed by some manner, permittedpar +by this License, of making, using, or selling its contributor version,par +but do not include claims that would be infringed only as apar +consequence of further modification of the contributor version. Forpar +purposes of this definition, "control" includes the right to grantpar +patent sublicenses in a manner consistent with the requirements ofpar +this License.par +par + Each contributor grants you a non-exclusive, worldwide, royalty-freepar +patent license under the contributor's essential patent claims, topar +make, use, sell, offer for sale, import and otherwise run, modify andpar +propagate the contents of its contributor version.par +par + In the following three paragraphs, a "patent license" is any expresspar +agreement or commitment, however denominated, not to enforce a patentpar +(such as an express permission to practice a patent or covenant not topar +sue for patent infringement). To "grant" such a patent license to apar +party means to make such an agreement or commitment not to enforce apar +patent against the party.par +par + If you convey a covered work, knowingly relying on a patent license,par +and the Corresponding Source of the work is not available for anyonepar +to copy, free of charge and under the terms of this License, through apar +publicly available network server or other readily accessible means,par +then you must either (1) cause the Corresponding Source to be sopar +available, or (2) arrange to deprive yourself of the benefit of thepar +patent license for this particular work, or (3) arrange, in a mannerpar +consistent with the requirements of this License, to extend the patentpar +license to downstream recipients. "Knowingly relying" means you havepar +actual knowledge that, but for the patent license, your conveying thepar +covered work in a country, or your recipient's use of the covered workpar +in a country, would infringe one or more identifiable patents in thatpar +country that you have reason to believe are valid.par +par + If, pursuant to or in connection with a single transaction orpar +arrangement, you convey, or propagate by procuring conveyance of, apar +covered work, and grant a patent license to some of the partiespar +receiving the covered work authorizing them to use, propagate, modifypar +or convey a specific copy of the covered work, then the patent licensepar +you grant is automatically extended to all recipients of the coveredpar +work and works based on it.par +par + A patent license is "discriminatory" if it does not include withinpar +the scope of its coverage, prohibits the exercise of, or ispar +conditioned on the non-exercise of one or more of the rights that arepar +specifically granted under this License. You may not convey a coveredpar +work if you are a party to an arrangement with a third party that ispar +in the business of distributing software, under which you make paymentpar +to the third party based on the extent of your activity of conveyingpar +the work, and under which the third party grants, to any of thepar +parties who would receive the covered work from you, a discriminatorypar +patent license (a) in connection with copies of the covered workpar +conveyed by you (or copies made from those copies), or (b) primarilypar +for and in connection with specific products or compilations thatpar +contain the covered work, unless you entered into that arrangement,par +or that patent license was granted, prior to 28 March 2007.par +par + Nothing in this License shall be construed as excluding or limitingpar +any implied license or other defenses to infringement that maypar +otherwise be available to you under applicable patent law.par +par + 12. No Surrender of Others' Freedom.par +par + If conditions are imposed on you (whether by court order, agreement orpar +otherwise) that contradict the conditions of this License, they do notpar +excuse you from the conditions of this License. If you cannot convey apar +covered work so as to satisfy simultaneously your obligations under thispar +License and any other pertinent obligations, then as a consequence you maypar +not convey it at all. For example, if you agree to terms that obligate youpar +to collect a royalty for further conveying from those to whom you conveypar +the Program, the only way you could satisfy both those terms and thispar +License would be to refrain entirely from conveying the Program.par +par + 13. Use with the GNU Affero General Public License.par +par + Notwithstanding any other provision of this License, you havepar +permission to link or combine any covered work with a work licensedpar +under version 3 of the GNU Affero General Public License into a singlepar +combined work, and to convey the resulting work. The terms of thispar +License will continue to apply to the part which is the covered work,par +but the special requirements of the GNU Affero General Public License,par +section 13, concerning interaction through a network will apply to thepar +combination as such.par +par + 14. Revised Versions of this License.par +par + The Free Software Foundation may publish revised and/or new versions ofpar +the GNU General Public License from time to time. Such new versions willpar +be similar in spirit to the present version, but may differ in detail topar +address new problems or concerns.par +par + Each version is given a distinguishing version number. If thepar +Program specifies that a certain numbered version of the GNU Generalpar +Public License "or any later version" applies to it, you have thepar +option of following the terms and conditions either of that numberedpar +version or of any later version published by the Free Softwarepar +Foundation. If the Program does not specify a version number of thepar +GNU General Public License, you may choose any version ever publishedpar +by the Free Software Foundation.par +par + If the Program specifies that a proxy can decide which futurepar +versions of the GNU General Public License can be used, that proxy'spar +public statement of acceptance of a version permanently authorizes youpar +to choose that version for the Program.par +par + Later license versions may give you additional or differentpar +permissions. However, no additional obligations are imposed on anypar +author or copyright holder as a result of your choosing to follow apar +later version.par +par + 15. Disclaimer of Warranty.par +par + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BYpar +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHTpar +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTYpar +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,par +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULARpar +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAMpar +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OFpar +ALL NECESSARY SERVICING, REPAIR OR CORRECTION.par +par + 16. Limitation of Liability.par +par + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITINGpar +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYSpar +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANYpar +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THEpar +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OFpar +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRDpar +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),par +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OFpar +SUCH DAMAGES.par +par + 17. Interpretation of Sections 15 and 16.par +par + If the disclaimer of warranty and limitation of liability providedpar +above cannot be given local legal effect according to their terms,par +reviewing courts shall apply local law that most closely approximatespar +an absolute waiver of all civil liability in connection with thepar +Program, unless a warranty or assumption of liability accompanies apar +copy of the Program in return for a fee.par +par + END OF TERMS AND CONDITIONSpar +par + How to Apply These Terms to Your New Programspar +par + If you develop a new program, and you want it to be of the greatestpar +possible use to the public, the best way to achieve this is to make itpar +free software which everyone can redistribute and change under these terms.par +par + To do so, attach the following notices to the program. It is safestpar +to attach them to the start of each source file to most effectivelypar +state the exclusion of warranty; and each file should have at leastpar +the "copyright" line and a pointer to where the full notice is found.par +par + par + Copyright (C) par +par + This program is free software: you can redistribute it and/or modifypar + it under the terms of the GNU General Public License as published bypar + the Free Software Foundation, either version 3 of the License, orpar + (at your option) any later version.par +par + This program is distributed in the hope that it will be useful,par + but WITHOUT ANY WARRANTY; without even the implied warranty ofpar + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See thepar + GNU General Public License for more details.par +par + You should have received a copy of the GNU General Public Licensepar + along with this program. If not, see .par +par +Also add information on how to contact you by electronic and paper mail.par +par + If the program does terminal interaction, make it output a shortpar +notice like this when it starts in an interactive mode:par +par + Copyright (C) par + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.par + This is free software, and you are welcome to redistribute itpar + under certain conditions; type `show c' for details.par +par +The hypothetical commands `show w' and `show c' should show the appropriatepar +parts of the General Public License. Of course, your program's commandspar +might be different; for a GUI interface, you would use an "about box".par +par + You should also get your employer (if you work as a programmer) or school,par +if any, to sign a "copyright disclaimer" for the program, if necessary.par +For more information on this, and how to apply and follow the GNU GPL, seepar +.par +par + The GNU General Public License does not permit incorporating your programpar +into proprietary programs. If your program is a subroutine library, youpar +may consider it more useful to permit linking proprietary applications withpar +the library. If this is what you want to do, use the GNU Lesser Generalpar +Public License instead of this License. But first, please readpar +.par + +} \ No newline at end of file