153 Commits

Author SHA1 Message Date
gsadmin 901692ead7 Merge pull request 'Release: backup restore, portable secret key, multi-arch image, CSRF, About dialog' (#14) from development into main
Release / release (push) Successful in 10m12s
Release / msi (push) Successful in 1m28s
Merge development into main (release)
2026.09.04.0056
2026-09-04 00:56:16 +00:00
Alphaeus Mote 39b39740a3 feat(ui): replace the notifications bell with an About dialog
The bell rendered hard-coded template data ("Roman Joined the Team!",
"New Payment received") — a demo artifact that had nothing to do with the
product and no backing feature.

In its place, an About dialog reports the running server's build: version,
build time, and commit, read from GET /api/v1/version, with a copy button so
the exact build can be quoted in a bug report without shelling onto the host.
That probe predates the {success,data} envelope and returns bare snake_case
JSON, so it is fetched directly rather than through the api client, accepting
either shape in case it is ever normalised. Dev builds ("dev"/"unknown")
degrade to a readable "—" rather than an invalid date.

Removing the bell also stranded three other unreferenced template files, so
Notification.tsx, AppLinks.tsx, QuickLinks.tsx and their shared data.ts (fake
users, chat/ecommerce app links) are deleted with it.

Also fixes the account button announcing itself to screen readers as
"show 11 new notifications".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 20:55:27 -04:00
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
gsadmin d0437df8e6 Merge pull request 'Release: authenticated API docs + route discovery, secret-key diagnostics' (#13) from development into main
Release / release (push) Successful in 9m12s
Release / msi (push) Successful in 1m58s
Merge development into main (release)
2026.09.03.1532
2026-09-03 15:32:33 +00: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
gsadmin 4a7cc9b69e Merge pull request 'Release: object viewer, roles, cert/firewall automation, API key fix' (#12) from development into main
Release / release (push) Successful in 9m9s
Release / msi (push) Successful in 1m32s
Merge development into main (release)
2026-09-03 02:26:19 +00: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 fbb308640c feat(ui): paginate long result lists
Add client-side pagination (TablePagination) to lists that could grow long
and cause endless scroll:
- Object Viewer search results
- Object attribute detail dialog
- Windows certificate store list (Security)
- Rule preview matched objects (previously hard-capped at 100 with no paging)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 22:08:51 -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 b2218f6be4 feat(ui): Object Viewer — browse & inspect directory objects to build filters
A new Object Viewer page: pick a connection, choose an object type and search
by name (or drop in a raw LDAP filter and a search base), list the matches,
and drill into any object to see all its attributes with copy buttons for the
attribute name and value — so operators can discover the exact attributes and
values to put in a rule filter.

Built on the existing query-preview endpoint (now typed as QueryPreviewResult);
the detail view requests all attributes (["*"]). Added to the Directory nav.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 21:32:51 -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 12ef83976d fix(ui): auto-list Windows store certificates when that source is selected
The Windows-store certificate list only loaded after clicking a separate
"List certificates" button, so selecting the Windows-store option appeared
to show nothing. Fetch the certificates automatically when the source is
chosen (and the store is supported); the button becomes a Refresh. The
backend endpoint and windowsStoreSupported flag were already correct.

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
gsadmin 2f408e3c6e Merge development into main (release)
Release / release (push) Successful in 9m22s
Release / msi (push) Successful in 1m30s
2026-09-02 23:03:11 +00: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 8ee9b95cde feat(ui): make dashboard widgets drill in
Count tiles link to their resource pages; the run-stat cards open Activity,
Recent Rule Runs opens Rule Runs, and Connection Health opens Connections.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 18:25:15 -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 154cadc792 feat(ui): per-rule history dialog showing objects added/removed
Add a History icon to each rule that opens a dialog listing the actual
objects this rule acted on — added, removed, moved — scoped to the rule via
the activity feed's ruleId filter, with category/result filters, paging, and
the per-action detail drill-in. Colour a "Completed" last-run result as
success (not amber) now that the rules list surfaces it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:48:28 -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 50147f357f feat(ui): show schedule + next run on the rules list
Add a Schedule column to the rules table that resolves each rule's
scheduleId to the schedule name and its next run time (or "Manual"),
pairing with the newly seeded built-in schedules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 17:14:56 -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 4c20fe1e15 feat(ui): live attribute + value pickers in the filter builder
The filter builder's attribute field now searches the selected connection's
full directory schema (scoped to the rule's object type) instead of a fixed
curated list, and the value field suggests the distinct values actually
present in the directory for the chosen attribute — both search-as-you-type
with the curated common attributes still shown first.

New AttributePicker and ValuePicker components (async MUI autocompletes over
ConnectionsApi.attributes / attributeValues), wired into each condition row;
memberOf still uses the group picker and raw-LDAP keeps a plain field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:58:02 -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 e513687b5f feat(ui): dynamic-group rule editor
Replace the old read-only rule form (which could only edit scalar metadata
and showed "author via config import" for conditions/actions) with a full
tabbed editor that authors the whole rule:

- Scope: connection, object type, and a searchable base-OU picker.
- Filter: a condition-group builder with attribute autocomplete (from the
  backend metadata), operator dropdowns, per-condition/-group NOT, AND/OR
  join within and across groups, and a raw-LDAP escape hatch. memberOf
  operators render a group picker for the value.
- Target & action: choose the action (Sync membership to group / Move to OU
  / Ensure group exists / Add to group), pick or paste the target group/OU,
  set the sync mode (Full / Managed / Add-only) and create-if-missing;
  multiple actions supported.
- Schedule: run manually, on an existing schedule, or create an interval or
  cron schedule inline.
- Preview: live dry-run against the directory (POST /rules/preview) showing
  match count, generated filter, planned +add/-remove, and sample matches.

New DirectoryPicker (search-or-type Autocomplete backed by the connection
directory endpoint) is reused for base OU, target group, target OU, and
memberOf values. API layer gains RuleMetadata/DirectoryObject/RuleInput
types and RulesApi.metadata/previewSpec + ConnectionsApi.directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 15:19:30 -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 6cb8e67c0c feat(ui): remove language selector from header
Drop the flag/language dropdown from both the vertical and horizontal
headers and delete the now-unused Language component. OrchestrAD ships a
single language, so the selector was dead UI chrome.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 14:08:44 -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 f0c81dfa51 feat(ui): Security page (OIDC + TLS config) and canonical name in preview
Add a Security admin page wiring the OIDC and TLS backends: an SSO/OIDC form
(issuer, client id/secret, redirect, scopes, claim mappings, default role) and a
TLS card showing the live certificate and a source picker - self-managed,
bring-your-own (PEM upload), or a Windows-store certificate (enumerate + select).
Add OidcApi/TlsApi clients + types, a Security nav entry, and a Canonical Name
column to the rule preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 13:06:30 -04:00
Alphaeus Mote 5674ca7849 feat(ui): dark-by-default, OrchestrAD branding, de-jarred login, SSO button
Default the theme to dark (toggle still available). Rebrand the logo to the
OrchestrAD icon + wordmark. Redesign the login into a calm, centered card on a
soft themed backdrop (removed the 3D character illustration and decorative
circles). Add a 'Sign in with SSO' button shown when OIDC is enabled, and adopt
the OIDC callback token from the URL fragment (hydrating the user via /auth/me).
Replace the hardcoded 'Mike/Admin' sidebar card with the real signed-in user and
a working sign-out; de-jar the search label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 12:59:14 -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 0e671ff34c feat(installer): wizard with install-dir + network dialogs and finish page
Add a WiX UI wizard (WixToolset.UI.wixext): Welcome -> License (GPLv3) ->
Install directory -> Network (listen address + port) -> Ready -> Finish. The
install dir, listen address, and port are recorded in the registry and the
service reads them; the finish page shows https://localhost:<port>/ with the
default admin/admin credentials and an optional 'open in browser' box. Schedule
InitializeService after WriteRegistryValues so the first start binds the chosen
port. CI msi job adds the UI extension. Verified: install to a custom dir + port
binds that port (health OK), registry recorded, upgrade + uninstall clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 11:51:52 -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