Addresses the gaps identified in the last audit.
Restore (was a stub returning "not yet implemented"). Every repository shares
one connection pool, so the database cannot be swapped underneath a live
server. Restore is therefore two-phase: RestoreBackup validates the file and
stages it beside the database; db.New applies it before the pool is opened,
which is the only safe moment. The database being replaced is preserved as
<db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot
replay the old journal over the restored file. Validation is strict — SQLite
integrity_check plus a schema probe — because applying an unrelated file
would destroy the install. GET/DELETE /api/v1/backups/restore inspect and
cancel a staged restore. The CLI does both phases at once, since it runs
standalone; `orchestrad backup` was also a stub and now works.
Secret key. With nothing configured the key is generated once and persisted
to <data>/secret.key, so restarts reuse it and moving the stack to another
server is a matter of copying the data directory. Upgrades are handled: if a
database already exists the install was silently running on the legacy
built-in default, so that value is adopted and written out rather than
replaced — generating a fresh key there would make every stored credential
undecryptable. The file is owner-only (ACL-restricted on Windows).
Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the
architectures the release binaries already covered. The Dockerfile
cross-compiles via TARGETARCH rather than emulating, so arm64 costs little.
CSRF: the middleware previously checked only that a header was *present* and
was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens
are now nonce + HMAC-SHA256 signed with the application secret, validated
properly, and the middleware is mounted on /api/v1. Bearer and API-key
requests are not CSRF-reachable and pass through untouched, so this is
transparent to the SPA and to API clients.
Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not
the source of truth — <data>/tls holds the key, so portability is unaffected
and a non-exportable server key is the better posture), the PFX password is
written to server.pfx.password beside the bundle so an operator importing it
by hand does not have to hunt for a password they never chose, and the
"renewed" log line now reflects whether a leaf was actually issued instead of
guessing from its age.
Verified live: backup -> stage -> restart applies and preserves the previous
database; secret key generated, adopted, and read back across restarts with
the credential check confirming decryptability; CSRF endpoint issues real
signed tokens.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stored credential secrets are encrypted with a key derived from
ORCHESTRAD_SECRET_KEY. When that value changes, the secrets are intact but
unreadable, and the only symptom was an opaque "decryption failed" surfacing
deep inside an unrelated operation:
"preview failed: building LDAP client: failed to decrypt credential:
decryption failed"
Nothing pointed at the real cause, so the error is now self-diagnosing:
- ErrDecryptionFailed states that the data was encrypted under a different
ORCHESTRAD_SECRET_KEY (or is corrupted). GCM auth failure on a well-formed
ciphertext is overwhelmingly a wrong-key case.
- The three credential decrypt sites name the credential, so the operator
knows which password to restore or re-enter.
- New services.CheckSecretKey verifies every stored secret against the
current key. It runs at startup (LogSecretKeyCheck) and in `doctor`, so a
mismatched key is reported once, loudly, at the moment it is first used
rather than during the next rule run. A correctly-sized but *different*
key passed doctor's existing length check and still broke every bind.
Not fatal: the server still starts, since an operator may be mid-migration
or may intend to re-enter the secrets.
Verified on the demo instance: starting with a wrong key logs
"1 of 1 stored credential secret(s) CANNOT be decrypted ... [OrchestrAD]",
and the rule preview error now names both the credential and the key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
APIKeyService.List scanned created_utc straight into time.Time, which the
modernc.org/sqlite driver returns as a string — surfacing as a 500 "Failed
to list API keys". Scan it into a string and parse. Regression test covers
the create+list round-trip (created_utc parsed, scope preserved).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The object viewer showed raw bytes for binary attributes. Format them for
display: objectGUID as a canonical GUID, objectSid/sIDHistory as S-1-… SID
strings, and any other non-printable value as base64. Applied in the
query-preview path that the object viewer uses; printable values pass through.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
API keys were never validated — the middleware only checked session tokens,
so an X-API-Key request always 401'd. Add auth.Service.ValidateAPIKey (looks
up the key hash, enforces enabled/revoked/expiry, loads the user + roles,
stamps last_used_utc) and route X-API-Key / bearer auth through it.
Add per-key scopes (migration 006): "read" (GET/HEAD only) or "readwrite"
(full access, default). The middleware rejects mutating requests from a
read-scoped key with 403. Create accepts a scope; list and create responses
include it; the UI create dialog has a scope selector and the list shows a
scope chip. Disable/re-enable and revoke (permanent) were already correct.
Verified live: RW key GET/POST ok; read key GET ok, POST 403; disable→401,
re-enable→200; revoke→401 and re-enable blocked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Database maintenance:
- New MaintenanceService prunes rule_runs (+ their action detail) and
audit_events older than their retention windows, then VACUUMs to reclaim
space. Runs once at startup and then on an interval, bound to the run
context. Configurable via ORCHESTRAD_RUN_RETENTION_DAYS (90),
ORCHESTRAD_AUDIT_RETENTION_DAYS (180),
ORCHESTRAD_MAINTENANCE_INTERVAL_HOURS (24), ORCHESTRAD_MAINTENANCE_VACUUM.
This stops the database growing forever. (Log rotation already existed via
lumberjack: ORCHESTRAD_LOG_MAX_SIZE_MB/_MAX_BACKUPS/_MAX_AGE_DAYS.)
Config import:
- Import now accepts either the wrapped {payload,dryRun} shape or a bare
exported config object, so a file downloaded from Export re-imports
directly (dryRun via ?dryRun=true) — useful for automation.
Test covers retention pruning with FK-cascaded action rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proxy / base-URL:
- ORCHESTRAD_TRUSTED_PROXIES now defaults to "local", trusting reverse
proxies in loopback + RFC1918 + link-local/ULA ranges out of the box, so
X-Forwarded-* (client IP, scheme, host) is honored behind an edge proxy
without extra config. New keywords: local/private, all/any, none.
- OIDC redirect URI derivation now uses the trust-gated request base URL
instead of reading X-Forwarded-Proto directly, and audit client IP now
trusts the middleware-rewritten RemoteAddr rather than the raw (spoofable)
X-Forwarded-For header. Both honor forwarded values only from trusted
peers.
Schedules:
- Seed eight built-in schedules on startup (every 5/15/30 min, hourly,
every 6/12h, daily, weekly), idempotent by name, so operators have
ready-made cadences in the Schedules page and the rule editor's schedule
dropdown without hand-building one.
Test covers the trusted-proxy keyword expansion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two directory introspection endpoints the rule filter builder uses:
- GET /ad-connections/{id}/attributes?objectType=&q= returns the schema
attributes that APPLY to the given object type (User/Computer/Group). The
applicable set is derived by walking the classSchema hierarchy from the
object's class up through subClassOf to top, plus auxiliary classes,
unioning each class's may/must-contain attributes; results are filtered by
substring and returned with adminDescription. The per-(connection,object
type) set is cached for 10 minutes so the walk is not repeated per
keystroke. (Computer inherits user attributes, since AD's computer class
subclasses user — reflected correctly.)
- GET /ad-connections/{id}/attribute-values?attribute=&objectType=&q= samples
objects and returns the distinct values present for one attribute, so the
value field can suggest real directory values.
Backed by a new bounded ldap Client.SearchWithLimit that tolerates the
server's size-limit response. Attribute names are validated against the LDAP
descriptor charset before use in a filter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turns rules into Adaxes/Active-Roles style dynamic groups. The core new
capability is a set-level SyncGroupMembership action that reconciles a
target group's membership against the matched object set in one pass
instead of the old add-only, per-object behaviour.
Engine / reconciliation:
- New ActionSyncGroupMembership runs once per target group after the
match: resolve (and optionally create) the group, read its current
members, diff against the matched set, and apply the adds/removes.
- Three per-rule sync modes (types.SyncMode): FullSync (membership ==
matched set; removes stale members incl. manual adds), ManagedAdd (adds
matches, removes only members this rule added), AddOnly (never removes).
- Managed ownership tracked in a new managed_group_members table
(migration 005) + repository, wired into the runner's engine so
ManagedAdd removes only what it added.
- Adds/removes are recorded as AddToGroup / RemoveFromGroupIfNoLongerMatched
run-actions so the activity feed categorises them as syncs/removals.
- Preview now computes an accurate, non-mutating diff for sync actions
(+add / -remove / already-in-sync counts and per-member entries).
- memberOf and memberOf-recursive (LDAP_MATCHING_RULE_IN_CHAIN) operators;
Regex no longer silently degrades to equals.
- Canonical group targets: CanonicalToLeafDN / NormalizeGroupTarget so a
target group can be given as domain.com/OU/Group as well as a DN.
Editor-facing APIs (backend-first; UI comes next):
- Rule create/update now accept conditionGroups + actions and persist them
via RuleRepository.ReplaceLogic (soft-delete + insert, preserving the
rule_run_actions FK). Omitting them leaves existing logic untouched.
- POST /api/v1/rules/preview evaluates an unsaved draft (live match panel).
- GET /api/v1/rules/metadata serves the operator vocabulary (object types,
operators, action types, sync modes, common attributes) so UI dropdowns
stay in lock-step with the backend.
- GET /api/v1/ad-connections/{id}/directory searches groups/OUs for the
target pickers.
Tests: reconciliation across all three modes + create-if-missing and the
missing-group error path (fake directory client); canonical leaf-DN
conversion; ReplaceLogic round-trip. Full suite green.
Note: RuleRepository.GetByID nests a query (getConditionGroups holds a
cursor while calling getConditions); safe under the production pool (25)
but a follow-up should flatten it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn the rule_run_actions ledger into operator intelligence. A new
ActivityService rolls the ledger up by time window (24h / 7d / all-time)
into syncs, operations and removals with success/failure counts and the
number of distinct objects affected, plus all-time totals per action type
and a most-active-rules ranking. A companion filtered, paginated feed
answers the operational questions directly: what action ran, what
happened, to which object, triggered by whom, and when.
Backend:
- ActivityService.Summary() and ListActions(filter) over the joined
rule_run_actions / rule_runs / rules tables, categorising each action
type as Sync (AddToGroup/AddGroupToGroup), Removal
(RemoveFromGroupIfNoLongerMatched) or Operation (everything else).
- GET /api/v1/activity/summary and GET /api/v1/activity (category,
actionType, status, ruleId, search, pagination).
- Table-driven test seeding a run with mixed action types/statuses and
asserting window roll-ups, distinct-object counts, top rules, and the
category/status feed filters.
Frontend:
- New Activity page: window summary cards, by-action-type and
most-active-rules tables, and a drill-in feed with category/result/
object filters and a detail dialog that pretty-prints the action's
details JSON and error.
- Wired into the vertical sidebar and horizontal navbar under Automation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add SettingsService.ResolveString/Bool/Int resolving runtime config as
app_settings (UI) > environment variable > default. Env seeds bootstrap; any
value set in the UI persists to app_settings and wins. Foundation for OIDC and
TLS UI configuration. Unit-tested for the precedence order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add /connections page with list view (hosts:port, root DN, TLS mode, enabled toggle, last test result) and per-row test/edit/delete actions. ConnectionFormDialog covers the full LDAP surface (hosts, port, TLS/StartTLS/invalid certs, root DN, optional bind DN, credential binding, search scope, paging). ConnectionTestDialog invokes the server-side test and renders each step (TCP, bind, root DN access) with latency. Add JSON tags to services.TestResult/TestDetail so the camelCase wire format matches the frontend ConnectionTestResult type.
Add UpdateEnabled methods on CredentialRepository, ConnectionRepository, and ScheduleRepository that toggle the is_enabled flag while preserving updated_utc. Expose corresponding Enable/Disable handlers and wire POST /{id}/enable and POST /{id}/disable routes under /api/v1/credentials, /api/v1/ad-connections, and /api/v1/schedules. Each toggle emits an audit event. Also finalizes CredentialService.Test and TestCredentialInput so the existing /credentials/{id}/test handler compiles and runs against an LDAP host using the stored username and decrypted secret.
Introduce ConfigService and /api/v1/config endpoints for system portability.
- Export: emits a versioned JSON document covering credentials (encrypted secrets preserved), connections, schedules, rules (with nested condition groups, conditions, and actions), and non-sensitive app settings.
- Import: validates the format version and upserts each entity by ID. Supports a dryRun mode that plans the import without writing, and emits per-entity warnings for secrets that need re-entry.
- Audit: Export and Import actions emit ConfigChange audit events with entity counts.
- Wiring: add ConfigService to server.Dependencies and instantiate it in cli.RunForeground.
APIKeyService.SetEnabled toggles the is_enabled flag, refusing to re-enable revoked keys. New POST /api/v1/api-keys/{id}/enable and /disable handlers expose the lifecycle transitions and emit Update audit events with the resulting state.
New DashboardService aggregates entity counts (rules, connections, credentials, schedules, users, api keys), rolling rule-run statistics (last 24h and 7d), the 10 most recent rule runs, and per-connection health based on last_tested_utc/last_test_result. Exposed via GET /api/v1/dashboard/summary and wired through server.Dependencies + cli.RunForeground.
New SettingsService provides List/Get/Upsert/Delete over the app_settings table. SettingsHandler exposes GET /api/v1/settings, GET/PUT/DELETE /api/v1/settings/{key} and redacts sensitive values in responses. Changes are recorded as ConfigChange audit events. The service is wired into server.Dependencies and constructed in cli.RunForeground.
- New ConnectionService.QueryPreview runs an ad-hoc LDAP search against an
existing AD connection. Caller supplies filter, optional baseDn (defaults
to the connection's rootDn), scope, attributes, and limit (capped at 500,
defaulting to 100) and gets back the matched entries as DN + attribute map
along with a truncated flag.
- Scope strings are mapped through goldap.Scope* constants so base, one,
and sub values all work.
- Wire POST /api/v1/ad-connections/{id}/query-preview to
ConnectionsHandler.QueryPreview and require a non-empty filter.
- New CredentialService.Test method that decrypts the stored secret and
performs an LDAP bind against a user-supplied host/port/TLS target.
- Persists the outcome on the credential via UpdateTestResult so the
last_tested_utc and last_test_result columns stay current.
- Wire POST /api/v1/credentials/{id}/test to CredentialsHandler.Test,
which accepts the connection parameters in the request body and maps
'credential not found' to 404.
- Extend RuleRepository with List/Update/SoftDelete/UpdateEnabled and
upsert helpers for condition groups, conditions, and actions.
- Extend RuleService with Create/GetByID/List/Update/Delete/Enable/Disable.
- Add List/Get/Create/Update/Delete/Enable/Disable handlers on RulesHandler
with request/response DTOs that decouple the API from storage models.
- Register the full CRUD route set on /api/v1/rules.
- Inject RuleService into server.Dependencies from cli.RunForeground.
- Add engine.Execute() that searches AD, iterates matches, runs actions,
honors StopOnError and PreviewOnly, and supports context cancellation
- Resolve base DN and search scope from rule override then connection defaults
- Add actionExecutor covering AddToGroup, AddGroupToGroup, EnsureGroupExists,
MoveToOu, and RemoveFromGroupIfNoLongerMatched with dynamic DN expansion
- Add RuleRunRepository persisting rule_runs and rule_run_actions
- Add Runner coordinator that loads rule+connection, builds an LDAP client,
invokes the engine, and records the run + per-action outcomes
- Expose ConnectionService.BuildClient / BuildLDAPConfig for reuse
- Scheduler now accepts a RuleRunner and invokes it when a scheduled rule fires
- Rule engine with preview and condition evaluation
- LDAP filter generation from rule conditions
- Connection service with full test workflow
- Credential service with encryption/decryption
- Rule service with validation
- Backup service with retention management
- Integration between services and repositories