Commit Graph

86 Commits

Author SHA1 Message Date
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
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>
2026-09-03 13:57:15 -04:00
Alphaeus Mote ba8808b6ab fix(tls): serialize Ensure/Reload
Ensure is reachable concurrently from the renewal loop (Manager.Start) and
the TLS settings handler (Reload on a config change). Nothing serialized
them, so an overlapping pair could issue leaves, rewrite <dir>, and mutate
the host certificate store underneath each other — and since the store prune
removes any superseded leaf, a racing pair could delete the very certificate
the other just installed. Guard the whole operation with a mutex.

The file-rewrite half of this race predates the store work; publishing to the
certificate store is what made the consequence bad enough to matter.

Also drops an unused parameter from the freeEnum helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:40:17 -04:00
Alphaeus Mote a0c937b876 feat(tls): publish the serving leaf to the Windows My store; fix context leak
Clients that resolve a certificate by store lookup rather than by reading our
PEM files had nothing to find: in auto mode the leaf lived only under
<data>/tls, and the My store was opened strictly read-only.

InstallLeafToMyStore imports the exported PKCS#12 into LocalMachine\My with
the private key persisted to the machine keyset (CNG KSP, matching the
ncryptSigner path used when serving *from* the store). Renewal is accounted
for: the new leaf is added with REPLACE_EXISTING, then pruneSupersededLeaves
removes any certificate sharing its subject *and* issuer, so the store holds
exactly one current leaf instead of one per renewal. Only certificates issued
by our own CA to our own subject are ever deleted - anything from another
issuer is left strictly alone. Best-effort: it needs admin rights and TLS
serving does not depend on it.

RemoveLeafFromMyStore runs on service removal, alongside the firewall rule,
so uninstalling leaves no orphaned certificate.

Also fixes a genuine leak found while auditing this code, in response to a
question about whether listing the store could damage it (it cannot - the
listing handle is read-only and stores are not exclusively locked):
ensureWindowsStore returned from inside the enumeration without freeing the
matched CertContext. CertEnumCertificatesInStore frees the previous context
each call and the last on completion, so only the early-return paths leaked -
and because CertCloseStore(store, 0) defers until outstanding contexts are
released, the store handle leaked with it, once per certificate load.

Verified on Windows 11: leaf appears in LocalMachine\My with a usable private
key and full SANs; forcing a re-issue replaces it (one cert, new thumbprint,
old one pruned) and leaves unrelated certificates untouched; HTTPS keeps
serving throughout. Cross-compiles for linux via the no-op stubs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:23:03 -04:00
Alphaeus Mote 5301b47f16 fix(crypto): explain that a decryption failure means the secret key changed
Stored credential secrets are encrypted with a key derived from
ORCHESTRAD_SECRET_KEY. When that value changes, the secrets are intact but
unreadable, and the only symptom was an opaque "decryption failed" surfacing
deep inside an unrelated operation:

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:59:43 -04:00
Alphaeus Mote e0b002975f feat(api-docs): require auth for docs and add filterable route discovery
The OpenAPI spec and Swagger UI were public. Put them behind the same
authentication as the rest of the API, and add a compact route list so a
client can ask "what can I call?" without opening dev tools.

Access:
- /api/openapi.json and /api/routes require a bearer token or API key.
- /api/docs additionally accepts a session cookie set at login, so a
  signed-in operator can open the docs in a new tab; an anonymous browser
  is redirected to /login?redirect=... and returned afterwards.
- The cookie is HttpOnly and path-scoped to /api/docs, so it is never sent
  to /api/v1/* and cannot authenticate an API call (no CSRF surface).
  Verified: cookie-only request to /api/v1/rules returns 401.

Discovery: both the spec and GET /api/routes accept ?method=get,post and
?path=<substring> (comma-separated, case-insensitive). The route list
returns method, path, summary, tag, public, and `allowed` — false when a
read-scoped API key cannot invoke that route. /api/docs passes the same
query through to the spec it loads.

UI: a </> icon in the header (both layouts) and an Administration → API
Docs menu entry, opened in a new tab via a new `external` nav-item flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 10:44:54 -04:00
Alphaeus Mote 35d8b81c91 fix(api-keys): scan created_utc as a string in List (modernc timestamp)
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>
2026-09-02 22:14:26 -04:00
Alphaeus Mote 6587905eeb fix(ldap): decode objectGUID/objectSid and binary attributes for display
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>
2026-09-02 22:01:34 -04:00
Alphaeus Mote 13b336a77e feat(connections): LDAPS port auto-flip + skip-cert default; keep 3 backups
- Toggling LDAPS in the connection form now auto-sets the port (636 on / 389
  off when it's still the other default) and enables "Allow invalid certs",
  because a non-domain-joined host usually can't validate the DC's LDAPS
  certificate. This fixes the common "existing connection was forcibly
  closed" seen when LDAPS was enabled while the port stayed 389 (dialing TLS
  to the plaintext port). Helper text explains the traffic is still encrypted.
- LDAP client sets tls ServerName to the host so verification succeeds when a
  DC cert IS trusted (ignored under InsecureSkipVerify).
- Backups retention reduced from 10 to 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 22:01:34 -04:00
Alphaeus Mote 2befe64a0c feat(install): idempotent Windows firewall rule; skip the blank MSI EULA
Firewall:
- On service initialize/install, create an idempotent inbound allow rule
  ("OrchestrAD") for the configured listen port, scoped to RFC 1918 private
  ranges plus CGNAT (10/8, 172.16/12, 192.168/16, 100.64/10). The rule is
  deleted-then-added so it always reflects the current port, and removed on
  service uninstall. Best-effort (needs admin; the MSI custom action and
  service run elevated); no-op off Windows. Verified the netsh rule lands
  with the expected port and remote-address scoping.

MSI:
- Skip the license/EULA page (Welcome now goes straight to the install
  directory), since it was blank. A standard short notice is kept in
  license.rtf only so the stock license control resolves at build time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 21:46:08 -04:00
Alphaeus Mote 27ff00d75c feat(tls): install self-managed CA into the Windows trust stores
When TLS is in auto (self-managed) mode, install the generated root into the
LocalMachine "Root" (Trusted Root CAs) store and the intermediate into the
"CA" (Intermediate CAs) store, so clients on this host trust the served
chain without manual import. Runs on every ensure/renewal with a replace
disposition, so it is idempotent and a renewed CA supersedes the previous
one. Best-effort (needs admin; the Windows service runs as LocalSystem);
a no-op on non-Windows.

Verified: OrchestrAD Root CA appears in LocalMachine\Root and the
Intermediate in LocalMachine\CA after startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 21:36:19 -04:00
Alphaeus Mote 6fdf9a9d54 feat(users): assign RBAC roles to users; OIDC default-role dropdown + clearer SSO config
Roles:
- Add GET /api/v1/roles (built-in SuperAdmin/Admin/Operator/Viewer) and
  wire role assignment into user create/update (UserRepository.SetRoles /
  GetRoleNames / ListRoles). User responses now include roles.
- User dialog gains a roles multi-select; the users list shows role chips.

OIDC / SSO config UI:
- Default role is now a dropdown populated from /roles.
- Issuer URL shows real provider examples (Entra/Okta/Google).
- Replace the confusing manual "Redirect URL" field with a read-only,
  auto-derived callback URL (from the browser origin / public URL) plus a
  copy button — the exact value to register at the IdP. The backend still
  auto-derives the callback and honors an env override.

Verified: /roles lists the four roles; creating/updating a user with roles
round-trips through GET.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 21:25:59 -04:00
Alphaeus Mote 069915415c feat(api): document request bodies, params, and auth in the OpenAPI spec
Make the API browsable/usable from Swagger UI without dev tools:
- Normalize chi's trailing slash on collection roots (Post("/")) so registry
  detail and pagination attach — previously POST /ad-connections etc. showed
  no request body.
- Document request bodies for the main create/update operations (connections,
  credentials, schedules, api-keys, users, settings, tls mode/certificate,
  query-preview, config import) with component schemas.
- Add standard page/pageSize query params to collection GETs.
- Add the X-API-Key security scheme alongside bearer so both auth methods
  show in the Authorize dialog.

Test covers the trailing-slash normalization + body attachment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 21:06:33 -04:00
Alphaeus Mote d3756bce80 feat(db): store the database under data/db and auto-migrate existing files
The SQLite database now lives at data/db/orchestrad.db instead of the data
root. db.New creates the db/ directory (SQLite will not) and, on first run,
moves a legacy data/orchestrad.db plus its -wal/-shm sidecars into db/ so
existing installations keep their data. Verified live: an existing demo
database migrated into data/db and all data (connections, rules, schedules)
was retained. Test covers the move + idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 20:41:34 -04:00
Alphaeus Mote dc0df06b92 chore(logging): default max log file size to 5 MB
Lower ORCHESTRAD_LOG_MAX_SIZE_MB default from 100 to 5 so log files roll
sooner by default; README updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 19:01:31 -04:00
Alphaeus Mote 4f949e6d8f fix(auth): make API keys actually authenticate; add read/read-write scopes
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>
2026-09-02 19:01:30 -04:00
Alphaeus Mote c74dd63281 feat(api): OpenAPI/Swagger docs generated from the router + PowerShell example
- GET /api/docs serves a Swagger UI and GET /api/openapi.json serves an
  OpenAPI 3 spec built by walking the live chi router, so documented paths
  always match what the build serves. A small registry adds rich detail
  (request bodies, params, schemas) for the automation-critical operations
  (auth login, rule create/update/preview, connection introspection);
  RuleInput and friends are defined as reusable component schemas.
- Add docs/examples/Create-OrchestrADRule.ps1: a no-alias PowerShell sample
  that builds headers/body as typed dictionaries, serializes with
  ConvertTo-Json, logs in, creates a SyncGroupMembership rule, and runs it.
- README: new "API & Automation" section (Swagger + PowerShell), and a
  "Logging & Retention" section documenting log rotation and the new
  database maintenance/retention knobs.

Tests cover spec generation from a router and registry well-formedness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 18:33:53 -04:00
Alphaeus Mote 1e38b3b99a feat(maintenance): history retention + VACUUM; robust config import
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>
2026-09-02 18:25:14 -04:00
Alphaeus Mote 429e8bd398 fix(rules): record last run outcome on the rule row
The runner updated the rule_runs history but never wrote the rules table's
last_run_utc / last_run_result columns, so the rules list showed blank Last
Run / Last Result. Add RuleRepository.UpdateLastRun and call it from the
runner's finalize and fail paths with the run's completion time and status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:48:27 -04:00
Alphaeus Mote 1f77473d8f feat(server): trust local proxies by default, seed built-in schedules
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>
2026-09-02 17:14:49 -04:00
Alphaeus Mote dc3c2c9811 feat(connections): schema attribute + distinct-value lookups for filter building
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>
2026-09-02 15:57:52 -04:00
Alphaeus Mote d5b8e693df fix(repository): flatten GetByID condition loading to avoid nested cursor
getConditionGroups held an open rows cursor while calling getConditions
per group, so each GetByID needed two pooled connections at once and could
deadlock under a small/exhausted pool. Read all groups first, close the
cursor, then load every group's conditions in a single bulk IN-query keyed
by condition_group_id. The repository round-trip test now runs against a
single-connection pool, which guards the no-nested-cursor invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:11:28 -04:00
Alphaeus Mote 5920b690d1 feat(rules): dynamic-group reconciliation + editor-facing APIs
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>
2026-09-02 14:57:30 -04:00
Alphaeus Mote 7effc09e8a fix(connections): List must return the full connection shape
The ad_connections List query selected only a subset of columns, so
use_start_tls, allow_invalid_certs, timeout_seconds, paging_enabled and
page_size came back as zero-values in the list response. Because the edit
form pre-populates directly from the list row, opening and saving a
connection silently overwrote those stored values with their zero values
— e.g. "Allow invalid certs" would never stick, and timeout/page size
reset to 0.

Select the full column set in List (matching GetByID) and scan the
previously dropped fields. Extend the repository round-trip test to set
these fields on create and assert List surfaces them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 14:15:36 -04:00
Alphaeus Mote e74cb454c8 feat(activity): add action-ledger intelligence and drill-in feed
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>
2026-09-02 14:09:04 -04:00
Alphaeus Mote dd183941dc fix(repository): parse TEXT timestamps as strings for modernc sqlite
The modernc.org/sqlite driver returns TEXT timestamp columns as strings
rather than time.Time (unlike the CGO mattn driver), so scanning directly
into *time.Time failed with "unsupported Scan" and surfaced as 500 errors
on the credentials and connections list endpoints.

Scan created/updated/started timestamps into strings and convert with the
existing parseTimeOrZero/parseNullTime helpers across credentials,
connections, schedules, rules, and rule_runs repositories. Add a
regression test that round-trips a credential and connection through a
migrated DB and asserts the timestamps come back parsed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 13:55:11 -04:00
Alphaeus Mote 7be92da2e6 feat(rules): canonical->DN targets and idempotent OU-path creation
Add CanonicalToOUDN/NormalizeOUTarget so an OU target can be given as a canonical
path (domain.com/OU/OU) or a DN, and Client.EnsureOUPath which idempotently
creates every OU down the path (parents before children). MoveToOu and group
creation now normalize the target and ensure the full OU path instead of only
the leaf. Unit-tested (conversion, escaped split) and verified against the test
AD: a canonical nested target creates each OU and moves the object; re-running is
a no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 12:47:26 -04:00
Alphaeus Mote c840ed054c feat(rules): expose operator-friendly canonical names for matched objects
Add ldap.CanonicalName: prefer the directory's canonicalName attribute (now
requested in searches) and otherwise build domain.com/OU/OU/CN from the DN.
Populate MatchedObject.CanonicalName and return it in the rule preview API so
operators see canonical names instead of raw DNs. Unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 12:43:09 -04:00
Alphaeus Mote b0decfe325 test(ad): opt-in integration tests exercising rules against a real AD
Add build-tagged (adtest) + env-gated integration tests that run the rule engine
against a live Active Directory over plain LDAP: create a unique test OU with
users/groups, execute a rule, and assert the AD outcome, then tree-delete the OU
on cleanup. Covers add-to-group-by-condition (matching users added, others not),
create-missing-group, and move-to-OU. Excluded from normal builds and CI; run
with -tags adtest and ORCHESTRAD_AD_TEST_* env. Verified green against the test
domain; no objects left behind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 12:09:45 -04:00
Alphaeus Mote 06b1c860d4 feat(auth): OIDC/SSO login with UI-configurable, env-overriding settings
Add backend OIDC: /api/v1/auth/oidc/status|login|callback (authorization code +
PKCE, state+nonce cookies, ID-token verification via go-oidc) and admin
/auth/oidc/config (GET/PUT, validates the issuer via discovery on enable).
Config resolves app_settings (UI) > env > default, so SSO can be configured from
the UI while env still seeds it. LoginOIDC provisions/links federated users by
the stable (issuer, subject) pair, refusing to reuse a username held by a
different account. Verified: discovery + real authorize redirect + PKCE cookies;
config API with DB-over-env precedence; provisioning unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 12:01:48 -04:00
Alphaeus Mote 6b4cd325c9 feat(config): read installer-recorded listen address/port from the registry
On Windows, resolve the listen address/port with precedence env > registry
(HKLM\Software\Grace Solutions\OrchestrAD ListenAddress/ListenPort, written by
the MSI wizard) > default. No-op off Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 11:51:52 -04:00
Alphaeus Mote 81468b8af1 feat(tls): real Windows My-store enumeration and CNG-backed serving
Implement ListWindowsCerts (LocalMachine + CurrentUser My stores: thumbprint,
subject, issuer, validity, SANs, has-private-key) and windows-store serving:
find the cert by thumbprint, acquire its CNG key, and serve TLS via a
crypto.Signer that signs through NCryptSignHash (RSA PKCS#1 v1.5 and PSS) so the
private key is never exported. Legacy CSP / non-RSA keys report a clear error.
Verified end to end: a CNG cert in LocalMachine\My serves a completed HTTPS
handshake (openssl confirms the served subject).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 11:30:48 -04:00
Alphaeus Mote 81753395be fix(tls): include per-adapter DNS suffixes in cert SANs
Connection-specific DNS suffixes are set per adapter (and via DHCP) without a
domain join, so read Domain/DhcpDomain from each Tcpip Interfaces subkey in
addition to the global Domain/NV Domain/SearchList. Verified on a workgroup host:
the cert now covers host.<adapter-suffix> and the suffix for every adapter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 11:23:20 -04:00
Alphaeus Mote a4698ee9f9 feat(tls): certificate modes (auto/provided/windows-store) + config API + richer SANs
Add runtime TLS modes resolved via settings (UI wins over env): auto
(self-managed), provided (bring-your-own upload), and windows-store (Windows
cert store, enumeration/serving scaffolded). New /api/v1/tls endpoints: status,
mode, certificate upload, and windows-store list; changes reload the live
manager without a restart, falling back to the self-managed cert if a configured
source is unavailable. Auto SANs now cover the hostname (CN), localhost, every
non-loopback IP, and host.<suffix> + the suffix for each detected DNS suffix
(Windows registry / resolv.conf). Bring-your-own is validated and unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 11:21:00 -04:00
Alphaeus Mote 300866d5cf feat(tls): auto-managed HTTPS with renewal; disable for edge termination
Add internal/tlsmgr: on startup, load-or-generate the CA, issue/reuse the leaf,
export the chain under <data>/tls, and hot-swap the cert on renewal via
GetCertificate. The server listens HTTPS when a manager is present. TLS is on by
default for the standalone binary/service (tls.enabled setting or
ORCHESTRAD_TLS_ENABLED, UI wins over env); the container image defaults it off so
it runs plain HTTP behind a TLS-terminating proxy. Startup logs a clickable URL.
Verified end to end: HTTPS serves, plain HTTP rejected, disable serves HTTP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 11:06:42 -04:00
Alphaeus Mote f38702dae5 feat(pki): self-managed CA (root+intermediate+leaf) with PEM/DER/PFX export
Add internal/pki: generate a Root CA -> Intermediate CA -> server leaf, re-issue
leaves under a persisted CA for renewal, and export the chain as PEM
(cert/key/fullchain), DER (.cer), and PKCS#12 (.pfx). Unit-tested: the chain
verifies, the PFX decodes with the CA chain, and a reloaded CA re-issues a
valid leaf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 11:06:42 -04:00
Alphaeus Mote 5eae8cc102 feat(services): DB-over-env settings precedence resolver
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>
2026-09-02 10:56:06 -04:00
Alphaeus Mote 07b2fe021d feat(config): default listen address 0.0.0.0:18090
Change the default HTTP port from 8080 to 18090 and propagate it through the
Dockerfile (EXPOSE + healthcheck), docker-compose, and the README. Host default
stays 0.0.0.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 10:56:05 -04:00
Alphaeus Mote 72d6f84bf7 fix(cli): pin service working directory to the executable dir
A service launched by the SCM inherits the manager's working directory
(C:\Windows\System32 on Windows), which put the default ./data there. When
running non-interactively, chdir to the executable's directory so the database
lands beside the installed binary (Program Files\OrchestrAD\data). Interactive
and container runs are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 09:27:35 -04:00
Alphaeus Mote 47679269f7 assets: add OrchestrAD app icon and Windows version-info manifest
Add a purpose-drawn icon (indigo/violet badge with an orchestration hub-and-spoke
glyph) as .svg, a 256px .png, and a multi-resolution .ico (16-256). versioninfo.json
drives goversioninfo to embed the icon + metadata into the Windows binaries; the
generated .syso objects are build artifacts and gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:55:34 -04:00
Alphaeus Mote 491b5abe83 feat(cli): idempotent initialize/remove service commands
Add initialize (install + start) and remove (stop + uninstall) that are safe to
re-run: initialize skips reinstall/restart when already up, remove treats a
not-installed service as done. install/uninstall become aliases. Uses the
service status to branch. Verified idempotent on Windows (repeat runs exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:55:33 -04:00
Alphaeus Mote 0debf9ab33 feat(cli): real cross-platform service management
Implement install/uninstall/start/stop via kardianos/service (Windows SCM,
systemd/upstart/sysv, launchd). The run command now goes through service.Run so
foreground/container and service execution share one startup path; install also
starts the service and uninstall stops then removes it. Route CLI and main
command output/errors through the centralized logger. Verified end to end as a
real Windows service (install -> RUNNING -> uninstall).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:49:15 -04:00
Alphaeus Mote 25424322f1 refactor(db): switch to pure-Go modernc.org/sqlite, drop CGO
Replace mattn/go-sqlite3 (CGO) with modernc.org/sqlite and the modernc-based
golang-migrate driver, translating the DSN pragmas. This lets every target
(win/mac/linux, amd64/arm64) cross-compile from one runner with no C toolchain,
so the Docker build now sets CGO_ENABLED=0 and drops gcc/musl-dev. Verified:
DB opens (WAL + foreign keys), migrations run, bootstrap seeds admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:49:14 -04:00
Alphaeus Mote 114ba24e05 refactor(logging): add a centralized process-wide logger
Introduce logging.Init/Default plus package-level Info/Warn/Error/Debug so
code without an injected *Logger (main, the service wrapper) emits the same
[UTC] - [Component] - [Level] - Message format on the one shared writer and
rotation policy. Existing injected usage is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:49:14 -04:00
Alphaeus Mote 1a87030e42 fix(server): answer HEAD on /health so container probes pass
The Docker HEALTHCHECK uses 'wget --spider' (a HEAD request), but the health
routes were GET-only and chi returned 405, which would mark the container
unhealthy. Register HEAD alongside GET on /health and /api/health.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:36:52 -04:00
Alphaeus Mote 7f824245d3 test: add config loader and health/version smoke tests
Cover the environment-driven config loader (defaults, overrides, secret
from file) and the unauthenticated /health and /api/v1/version handlers
the Docker HEALTHCHECK and bootstrap flow depend on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 00:13:35 -04:00
GraceSolutions 611f736088 feat(auth): default admin/admin bootstrap with forced first-login password change
- Add password_reset_required column (migration 004) + repository support
- auth.Service.ChangePassword verifies current, hashes new, clears flag,
  emits PasswordChange audit events for success and failure
- Bootstrap: when no ORCHESTRAD_BOOTSTRAP_PASSWORD[_FILE] is set, seed
  admin/admin with password_reset_required=true and log a one-time warn
  banner; env/file-supplied passwords keep the flag clear
- Expose passwordResetRequired in UserInfo / /auth/me / login response
- POST /api/v1/auth/change-password behind the authenticated group
- Frontend: /change-password page + ChangePasswordForm, AuthLogin and
  RequireAuth bounce any other route to it while the flag is set
- Docs: DesignSpecification 8.6/8.8 and Template 7.6/7.7 rewritten,
  Trusted Proxy renumbered to 7.8 in the template, acceptance items
  updated to match the new default-credential behavior
2026-04-23 17:36:23 -04:00
GraceSolutions 329825ce86 fix(auth): sanitize redirect, read token from storage, 301 legacy /auth/* paths
- Add sanitizeRedirect/safeRedirectTarget helper; reject anything that isn't
  a safe local path (must start with a single /, no scheme, no whitespace,
  not /login itself). Apply in RequireAuth when encoding the current pathname
  and in AuthLogin when consuming ?redirect=.
- Make configureApi's getToken read localStorage directly via readCurrentToken
  so the first request after login cannot race the AuthContext re-render that
  previously owned the token via a React closure.
- Redirect legacy /auth/* (including the pre-flatten /auth/auth1/login) with
  HTTP 301 to /login in the webui handler so stale bookmarks can't seed the
  SPA router with a malformed URL.
- Regression test TestHandler_LegacyAuthPathRedirectsToLogin covering four
  legacy path shapes.
2026-04-23 17:12:41 -04:00
GraceSolutions 1a24ed5276 feat(server): trusted-proxy middleware and gate admin routes behind auth
Two server-layer changes that were developed together because they
share the same routing rewrite:

- proxy.go: TrustedProxy middleware inspects RemoteAddr against the
  operator-configured CIDR list (ORCHESTRAD_TRUSTED_PROXIES) and, only
  when the peer matches, rewrites r.RemoteAddr and r.URL.Scheme from
  X-Forwarded-For / X-Forwarded-Proto. Untrusted peers are ignored so
  downstream handlers and the audit log see the direct connection
  address, preventing header spoofing.

- server.go: /api/v1 is split into a public surface (version, health,
  auth login/logout/csrf) and an authenticated group that now wraps
  /users, /credentials, /ad-connections, /schedules, /rules, /rule-runs,
  /backups, /api-keys, /audit, /settings, /dashboard, and /config with
  AuthMiddleware. Previously only /auth/me and two /api-keys routes
  were protected; every other administrative endpoint was reachable
  without a token, which was a real security bug.
2026-04-23 16:40:36 -04:00
GraceSolutions 336d60926b feat(auth): seed initial admin user from env on first run
When the users table is empty on startup (typical first boot into a
fresh data directory), create a single Admin-role user called admin
so the operator has something to log in with before an IdP is wired.

Password source precedence:
  1. ORCHESTRAD_BOOTSTRAP_PASSWORD (or _FILE) if set
  2. A 24-byte hex string generated at random and printed once to
     stdout and to the structured log

The generated-password path logs a bright one-time banner with the
username and password, intended to be captured from the console on the
very first run and then forgotten. No password is ever written to a
file on disk by this flow.

Wired from cmd/run via cli.go so that every run invocation checks the
seed state after migrations have been applied.
2026-04-23 16:40:25 -04:00
GraceSolutions f9da239a41 feat(config): env/file secrets, bootstrap password, trusted proxies, allowed origins
Adds four related pieces of first-run configuration surface so the
single binary can be dropped behind a reverse proxy or into a container
with sensible defaults:

- getEnvOrFile: any ORCHESTRAD_* value may be supplied either directly
  via environment variable or indirectly via ORCHESTRAD_*_FILE pointing
  at a file on disk, matching the Docker / Kubernetes secret idiom.
- ORCHESTRAD_BOOTSTRAP_USERNAME and ORCHESTRAD_BOOTSTRAP_PASSWORD drive
  the first-run admin seed (consumed by auth.Bootstrap in a later commit).
- ORCHESTRAD_TRUSTED_PROXIES accepts a comma-separated list of CIDRs
  whose X-Forwarded-* headers will be honored by the proxy middleware.
- ORCHESTRAD_ALLOWED_ORIGINS replaces the hardcoded localhost:3000 CORS
  allowlist; empty means no cross-origin access, which is the correct
  default for the same-origin embedded-UI deployment.
2026-04-23 16:40:14 -04:00