From d3756bce8069a0aba7df9eed1045288d0cd32290 Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 20:41:34 -0400 Subject: [PATCH 01/11] 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 --- backend/internal/config/config.go | 2 +- backend/internal/db/db.go | 36 ++++++++++++++++++++++++++ backend/internal/db/db_test.go | 42 +++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 backend/internal/db/db_test.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a836ac8..920ba73 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -107,7 +107,7 @@ func Load() (*Config, error) { TrustedProxies: splitCSV(getEnv("ORCHESTRAD_TRUSTED_PROXIES", "local")), }, Database: DatabaseConfig{ - Path: filepath.Join(dataPath, "orchestrad.db"), + Path: filepath.Join(dataPath, "db", "orchestrad.db"), MaxOpenConns: getEnvInt("ORCHESTRAD_DB_MAX_OPEN_CONNS", 25), MaxIdleConns: getEnvInt("ORCHESTRAD_DB_MAX_IDLE_CONNS", 5), WALMode: true, diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index fc52635..99e99ff 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -5,6 +5,8 @@ import ( "database/sql" "embed" "fmt" + "os" + "path/filepath" "github.com/Grace-Solutions/OrchestrAD/internal/config" "github.com/Grace-Solutions/OrchestrAD/internal/logging" @@ -26,6 +28,16 @@ type DB struct { // New creates a new database connection with the given configuration func New(cfg config.DatabaseConfig, logger *logging.Logger) (*DB, error) { + // Ensure the database directory exists (SQLite will not create it), and + // migrate a legacy database from the data-root into the db/ subdirectory so + // existing installs keep their data after the path change. + if dir := filepath.Dir(cfg.Path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("creating database directory: %w", err) + } + migrateLegacyDBLocation(cfg.Path, logger) + } + logger.Info("Database", "Opening database at %s", cfg.Path) // Build connection string with pragmas. modernc.org/sqlite applies these @@ -57,6 +69,30 @@ func New(cfg config.DatabaseConfig, logger *logging.Logger) (*DB, error) { }, nil } +// migrateLegacyDBLocation moves a database (and its -wal/-shm sidecars) from the +// old data-root location into the new db/ subdirectory, once, when the new file +// does not yet exist. It is best-effort: failures are logged, not fatal. +func migrateLegacyDBLocation(newPath string, logger *logging.Logger) { + if _, err := os.Stat(newPath); err == nil { + return // new database already present + } + dbDir := filepath.Dir(newPath) + legacy := filepath.Join(filepath.Dir(dbDir), filepath.Base(newPath)) + if _, err := os.Stat(legacy); err != nil { + return // nothing to migrate + } + for _, suffix := range []string{"", "-wal", "-shm"} { + from, to := legacy+suffix, newPath+suffix + if _, err := os.Stat(from); err != nil { + continue + } + if err := os.Rename(from, to); err != nil { + logger.Warn("Database", "Could not move legacy database file %s: %v", from, err) + } + } + logger.Info("Database", "Migrated existing database into %s", dbDir) +} + // Conn returns the underlying sql.DB connection func (db *DB) Conn() *sql.DB { return db.conn diff --git a/backend/internal/db/db_test.go b/backend/internal/db/db_test.go new file mode 100644 index 0000000..6dced2d --- /dev/null +++ b/backend/internal/db/db_test.go @@ -0,0 +1,42 @@ +package db + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Grace-Solutions/OrchestrAD/internal/logging" +) + +func TestMigrateLegacyDBLocation(t *testing.T) { + data := t.TempDir() + newPath := filepath.Join(data, "db", "orchestrad.db") + if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil { + t.Fatal(err) + } + + // Seed a legacy database + WAL/SHM sidecars in the data root. + for _, suffix := range []string{"", "-wal", "-shm"} { + if err := os.WriteFile(filepath.Join(data, "orchestrad.db"+suffix), []byte("x"+suffix), 0644); err != nil { + t.Fatal(err) + } + } + + migrateLegacyDBLocation(newPath, logging.Default()) + + // Files moved into db/, originals gone. + for _, suffix := range []string{"", "-wal", "-shm"} { + if _, err := os.Stat(newPath + suffix); err != nil { + t.Errorf("expected %s in db/, got error: %v", newPath+suffix, err) + } + if _, err := os.Stat(filepath.Join(data, "orchestrad.db"+suffix)); !os.IsNotExist(err) { + t.Errorf("legacy file orchestrad.db%s should be gone", suffix) + } + } + + // Second run is a no-op (new file already exists). + migrateLegacyDBLocation(newPath, logging.Default()) + if _, err := os.Stat(newPath); err != nil { + t.Errorf("new db should still exist after second run: %v", err) + } +} -- 2.52.0 From 12ef83976d41cf19a28735a1d0b6112644c772a9 Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 21:06:33 -0400 Subject: [PATCH 02/11] 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 --- frontend/src/app/(app)/security/page.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/(app)/security/page.tsx b/frontend/src/app/(app)/security/page.tsx index 6632e11..8d66918 100644 --- a/frontend/src/app/(app)/security/page.tsx +++ b/frontend/src/app/(app)/security/page.tsx @@ -245,6 +245,15 @@ function TlsCard() { } }; + // Auto-list the Windows store certificates as soon as that source is chosen, + // so the operator does not have to hit a separate button to see them. + useEffect(() => { + if (mode === "windows-store" && status?.windowsStoreSupported && winCerts === null) { + void loadWinCerts(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, status?.windowsStoreSupported]); + return ( {loading ? ( @@ -325,7 +334,7 @@ function TlsCard() { {winErr ? {winErr} : null} -- 2.52.0 From 069915415cd9e074c8c3f7d514018aba49812b31 Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 21:06:33 -0400 Subject: [PATCH 03/11] feat(api): document request bodies, params, and auth in the OpenAPI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/internal/api/openapi.go | 149 ++++++++++++++++++++++++++- backend/internal/api/openapi_test.go | 42 ++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) diff --git a/backend/internal/api/openapi.go b/backend/internal/api/openapi.go index 1c90808..34e987b 100644 --- a/backend/internal/api/openapi.go +++ b/backend/internal/api/openapi.go @@ -49,6 +49,10 @@ func (h *OpenAPIHandler) UI(w http.ResponseWriter, r *http.Request) { var pathParamRe = regexp.MustCompile(`\{([^}]+)\}`) +// collectionGetRe matches a single-segment collection under /api/v1 (e.g. +// /api/v1/rules), which take pagination query parameters. +var collectionGetRe = regexp.MustCompile(`^/api/v1/[a-z0-9-]+$`) + // BuildOpenAPISpec walks the router and assembles an OpenAPI 3.0 document. func BuildOpenAPISpec(router chi.Router) map[string]any { paths := map[string]map[string]any{} @@ -59,6 +63,12 @@ func BuildOpenAPISpec(router chi.Router) map[string]any { return nil } route = strings.TrimSuffix(route, "/*") + // chi emits collection roots registered as "/" with a trailing slash + // (e.g. /api/v1/rules/); normalize so registry keys and the pagination + // matcher line up and the documented path is clean. + if len(route) > 1 { + route = strings.TrimRight(route, "/") + } if route == "" { return nil } @@ -85,17 +95,34 @@ func BuildOpenAPISpec(router chi.Router) map[string]any { "servers": []any{map[string]any{"url": "/", "description": "This server"}}, "security": []any{ map[string]any{"bearerAuth": []any{}}, + map[string]any{"apiKeyAuth": []any{}}, }, "paths": pathsOut, "components": map[string]any{ "securitySchemes": map[string]any{ - "bearerAuth": map[string]any{"type": "http", "scheme": "bearer"}, + "bearerAuth": map[string]any{ + "type": "http", "scheme": "bearer", + "description": "Session token from POST /api/v1/auth/login, sent as `Authorization: Bearer `.", + }, + "apiKeyAuth": map[string]any{ + "type": "apiKey", "in": "header", "name": "X-API-Key", + "description": "An API key. Read-scoped keys may call GET only.", + }, }, "schemas": openAPISchemas(), }, } } +// listQueryParams documents the standard pagination query parameters shared by +// the collection GET endpoints. +func listQueryParams() []any { + return []any{ + map[string]any{"name": "page", "in": "query", "schema": map[string]any{"type": "integer", "default": 1}}, + map[string]any{"name": "pageSize", "in": "query", "schema": map[string]any{"type": "integer", "default": 20, "maximum": 100}}, + } +} + // publicRoutes need no auth; their operations advertise empty security. var publicRoutes = map[string]bool{ "/health": true, @@ -127,6 +154,9 @@ func operationFor(method, route string) map[string]any { } if len(params) > 0 { op["parameters"] = params + } else if method == http.MethodGet && collectionGetRe.MatchString(route) && route != "/api/v1/version" && route != "/api/v1/health" { + // Collection GET endpoints accept standard pagination. + op["parameters"] = listQueryParams() } if publicRoutes[route] { op["security"] = []any{} @@ -227,6 +257,20 @@ var operationRegistry = map[string]map[string]any{ "GET /api/v1/activity": { "summary": "Action feed (filter by ruleId, category, status, actionType, search)", }, + "POST /api/v1/ad-connections": {"summary": "Create an AD connection", "requestBody": jsonBody("ConnectionInput")}, + "PUT /api/v1/ad-connections/{id}": {"summary": "Update an AD connection", "requestBody": jsonBody("ConnectionInput")}, + "POST /api/v1/credentials": {"summary": "Create a credential", "requestBody": jsonBody("CredentialInput")}, + "PUT /api/v1/credentials/{id}": {"summary": "Update a credential", "requestBody": jsonBody("CredentialInput")}, + "POST /api/v1/schedules": {"summary": "Create a schedule", "requestBody": jsonBody("ScheduleInput")}, + "PUT /api/v1/schedules/{id}": {"summary": "Update a schedule", "requestBody": jsonBody("ScheduleInput")}, + "POST /api/v1/api-keys": {"summary": "Create an API key (returns the key once)", "requestBody": jsonBody("APIKeyInput")}, + "POST /api/v1/users": {"summary": "Create a user", "requestBody": jsonBody("UserInput")}, + "PUT /api/v1/users/{id}": {"summary": "Update a user", "requestBody": jsonBody("UserInput")}, + "PUT /api/v1/settings/{key}": {"summary": "Create or update a setting", "requestBody": jsonBody("SettingInput")}, + "POST /api/v1/tls/mode": {"summary": "Set the TLS certificate source", "requestBody": jsonBody("TLSModeInput")}, + "POST /api/v1/tls/certificate": {"summary": "Upload a bring-your-own certificate", "requestBody": jsonBody("TLSCertificateInput")}, + "POST /api/v1/config/import": {"summary": "Import configuration (wrapped {payload,dryRun} or a bare exported config)", "requestBody": jsonBody("ConfigImport")}, + "POST /api/v1/ad-connections/{id}/query-preview": {"summary": "Run an ad-hoc LDAP query", "requestBody": jsonBody("QueryPreviewInput")}, } func openAPISchemas() map[string]any { @@ -300,6 +344,109 @@ func openAPISchemas() map[string]any { }, }, }, + "ConnectionInput": map[string]any{ + "type": "object", + "required": []any{"name", "hosts", "rootDn"}, + "properties": map[string]any{ + "name": str, "description": str, "isEnabled": boolean, + "hosts": map[string]any{"type": "string", "description": "Comma-separated host(s)"}, + "port": map[string]any{"type": "integer", "example": 636}, + "useTls": boolean, + "useStartTls": boolean, + "allowInvalidCerts": boolean, + "rootDn": map[string]any{"type": "string", "example": "DC=corp,DC=com"}, + "bindDn": str, + "credentialId": str, + "defaultSearchScope": map[string]any{"type": "string", "enum": []any{"Base", "OneLevel", "Subtree"}}, + "timeoutSeconds": map[string]any{"type": "integer", "example": 30}, + "pagingEnabled": boolean, + "pageSize": map[string]any{"type": "integer", "example": 1000}, + }, + }, + "CredentialInput": map[string]any{ + "type": "object", + "required": []any{"name", "credentialType"}, + "properties": map[string]any{ + "name": str, "description": str, + "credentialType": map[string]any{"type": "string", "example": "UsernamePassword"}, + "username": map[string]any{"type": "string", "example": "CORP\\svc-orchestrad"}, + "password": map[string]any{"type": "string", "description": "Write-only; never returned"}, + "isEnabled": boolean, + }, + }, + "ScheduleInput": map[string]any{ + "type": "object", + "required": []any{"name", "scheduleKind"}, + "properties": map[string]any{ + "name": str, "description": str, "isEnabled": boolean, + "scheduleKind": map[string]any{"type": "string", "enum": []any{"Easy", "Cron"}}, + "easyIntervalValue": map[string]any{"type": "integer", "example": 15}, + "easyIntervalUnit": map[string]any{"type": "string", "enum": []any{"Minutes", "Hours", "Days"}}, + "cronExpression": map[string]any{"type": "string", "example": "0 0 * * * *", "description": "6-field (with seconds), UTC"}, + "timezoneMode": str, + }, + }, + "APIKeyInput": map[string]any{ + "type": "object", + "required": []any{"name"}, + "properties": map[string]any{ + "name": str, + "scope": map[string]any{"type": "string", "enum": []any{"read", "readwrite"}, "default": "readwrite"}, + "userId": map[string]any{"type": "string", "description": "Defaults to the caller"}, + "expiresAt": map[string]any{"type": "string", "format": "date-time"}, + }, + }, + "UserInput": map[string]any{ + "type": "object", + "required": []any{"username"}, + "properties": map[string]any{ + "username": str, "email": str, "displayName": str, + "password": map[string]any{"type": "string", "description": "Write-only"}, + "isActive": boolean, + "roles": map[string]any{"type": "array", "items": str}, + }, + }, + "SettingInput": map[string]any{ + "type": "object", + "required": []any{"value"}, + "properties": map[string]any{ + "value": str, "valueType": str, "description": str, "isSensitive": boolean, + }, + }, + "TLSModeInput": map[string]any{ + "type": "object", + "required": []any{"mode"}, + "properties": map[string]any{ + "mode": map[string]any{"type": "string", "enum": []any{"auto", "provided", "windows-store"}}, + "windowsThumbprint": map[string]any{"type": "string", "description": "Required when mode is windows-store"}, + }, + }, + "TLSCertificateInput": map[string]any{ + "type": "object", + "required": []any{"certificatePem", "privateKeyPem"}, + "properties": map[string]any{ + "certificatePem": str, "privateKeyPem": str, "chainPem": str, + }, + }, + "QueryPreviewInput": map[string]any{ + "type": "object", + "required": []any{"filter"}, + "properties": map[string]any{ + "baseDn": str, + "scope": map[string]any{"type": "string", "enum": []any{"base", "one", "subtree"}}, + "filter": map[string]any{"type": "string", "example": "(objectClass=user)"}, + "attributes": map[string]any{"type": "array", "items": str}, + "limit": map[string]any{"type": "integer", "example": 100}, + }, + }, + "ConfigImport": map[string]any{ + "type": "object", + "description": "Either {payload:, dryRun:bool} or a bare exported config object.", + "properties": map[string]any{ + "payload": map[string]any{"type": "object"}, + "dryRun": boolean, + }, + }, } } diff --git a/backend/internal/api/openapi_test.go b/backend/internal/api/openapi_test.go index 3370125..3df0b0e 100644 --- a/backend/internal/api/openapi_test.go +++ b/backend/internal/api/openapi_test.go @@ -49,6 +49,48 @@ func TestBuildOpenAPISpecFromRouter(t *testing.T) { } } +// TestCollectionRoutesNormalizeTrailingSlash guards the real router shape where +// a collection root is registered as Post("/") under Route("/x"), which chi +// emits with a trailing slash. The spec must normalize it so the request body +// and pagination params still attach. +func TestCollectionRoutesNormalizeTrailingSlash(t *testing.T) { + r := chi.NewRouter() + noop := func(w http.ResponseWriter, _ *http.Request) {} + r.Route("/api/v1", func(r chi.Router) { + r.Route("/ad-connections", func(r chi.Router) { + r.Get("/", noop) + r.Post("/", noop) + }) + }) + + paths := BuildOpenAPISpec(r)["paths"].(map[string]any) + conn, ok := paths["/api/v1/ad-connections"].(map[string]any) + if !ok { + t.Fatalf("collection route not normalized; got paths: %v", keysOf(paths)) + } + post := conn["post"].(map[string]any) + body, ok := post["requestBody"].(map[string]any) + if !ok { + t.Fatalf("POST collection missing requestBody") + } + ref := body["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["$ref"] + if ref != "#/components/schemas/ConnectionInput" { + t.Errorf("unexpected body schema ref: %v", ref) + } + get := conn["get"].(map[string]any) + if _, ok := get["parameters"]; !ok { + t.Errorf("GET collection should have pagination parameters") + } +} + +func keysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + // TestOperationRegistryWellFormed guards against typos: every registry key must // be " /api/..." so it can only enrich a real API route. func TestOperationRegistryWellFormed(t *testing.T) { -- 2.52.0 From 6fdf9a9d54154d3eba1a351df5f912afa9a50f6a Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 21:25:59 -0400 Subject: [PATCH 04/11] feat(users): assign RBAC roles to users; OIDC default-role dropdown + clearer SSO config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/internal/api/users_handlers.go | 92 +++++++++++++++---- backend/internal/repository/users.go | 82 +++++++++++++++++ backend/internal/server/server.go | 3 + frontend/src/app/(app)/security/page.tsx | 58 ++++++++++-- .../src/app/(app)/users/UserFormDialog.tsx | 23 ++++- frontend/src/app/(app)/users/page.tsx | 8 ++ frontend/src/lib/api/resources.ts | 9 +- frontend/src/lib/api/types.ts | 6 ++ 8 files changed, 249 insertions(+), 32 deletions(-) diff --git a/backend/internal/api/users_handlers.go b/backend/internal/api/users_handlers.go index 4cc5768..7b70b73 100644 --- a/backend/internal/api/users_handlers.go +++ b/backend/internal/api/users_handlers.go @@ -25,26 +25,54 @@ func NewUsersHandler(repo *repository.UserRepository, auditService *audit.Servic return &UsersHandler{repo: repo, auditService: auditService, logger: logger} } -// UserRequest represents a user create/update request +// UserRequest represents a user create/update request. Roles, when present, +// replace the user's role assignments. type UserRequest struct { - Username string `json:"username"` - Email *string `json:"email,omitempty"` - DisplayName *string `json:"displayName,omitempty"` - Password *string `json:"password,omitempty"` - IsActive *bool `json:"isActive,omitempty"` + Username string `json:"username"` + Email *string `json:"email,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Password *string `json:"password,omitempty"` + IsActive *bool `json:"isActive,omitempty"` + Roles *[]string `json:"roles,omitempty"` } // UserResponse represents a user in responses (no password material) type UserResponse struct { - ID string `json:"id"` - Username string `json:"username"` - Email *string `json:"email,omitempty"` - DisplayName *string `json:"displayName,omitempty"` - IsActive bool `json:"isActive"` - IsOIDCUser bool `json:"isOidcUser"` - LastLoginUTC *string `json:"lastLoginUtc,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Username string `json:"username"` + Email *string `json:"email,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + IsActive bool `json:"isActive"` + IsOIDCUser bool `json:"isOidcUser"` + Roles []string `json:"roles"` + LastLoginUTC *string `json:"lastLoginUtc,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +// RoleResponse is a role offered for assignment. +type RoleResponse struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// ListRoles handles GET /api/v1/roles — the assignable roles. +func (h *UsersHandler) ListRoles(w http.ResponseWriter, r *http.Request) { + roles, err := h.repo.ListRoles() + if err != nil { + h.logger.Error("UsersHandler", "ListRoles failed: %v", err) + WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to list roles") + return + } + resp := make([]RoleResponse, 0, len(roles)) + for _, ro := range roles { + rr := RoleResponse{Name: ro.Name} + if ro.Description != nil { + rr.Description = *ro.Description + } + resp = append(resp, rr) + } + WriteJSON(w, http.StatusOK, resp) } // List handles GET /api/v1/users @@ -59,7 +87,11 @@ func (h *UsersHandler) List(w http.ResponseWriter, r *http.Request) { } resp := make([]UserResponse, 0, len(users)) for i := range users { - resp = append(resp, userToResponse(&users[i])) + ur := userToResponse(&users[i]) + if names, err := h.repo.GetRoleNames(users[i].ID); err == nil { + ur.Roles = names + } + resp = append(resp, ur) } WriteList(w, resp, p.Page, p.PageSize, total) } @@ -77,7 +109,11 @@ func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusNotFound, ErrCodeNotFound, "User not found") return } - WriteJSON(w, http.StatusOK, userToResponse(user)) + resp := userToResponse(user) + if names, err := h.repo.GetRoleNames(user.ID); err == nil { + resp.Roles = names + } + WriteJSON(w, http.StatusOK, resp) } // Create handles POST /api/v1/users @@ -128,9 +164,18 @@ func (h *UsersHandler) Create(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to create user") return } + if req.Roles != nil { + if err := h.repo.SetRoles(user.ID, *req.Roles); err != nil { + h.logger.Warn("UsersHandler", "SetRoles failed for %s: %v", user.ID, err) + } + } emitAudit(h.auditService, r, audit.EventCreate, "User", user.ID, "Create", true, map[string]any{"username": user.Username}, "") - WriteJSON(w, http.StatusCreated, userToResponse(user)) + resp := userToResponse(user) + if names, err := h.repo.GetRoleNames(user.ID); err == nil { + resp.Roles = names + } + WriteJSON(w, http.StatusCreated, resp) } // Update handles PUT /api/v1/users/{id} @@ -176,9 +221,18 @@ func (h *UsersHandler) Update(w http.ResponseWriter, r *http.Request) { WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to update user") return } + if req.Roles != nil { + if err := h.repo.SetRoles(existing.ID, *req.Roles); err != nil { + h.logger.Warn("UsersHandler", "SetRoles failed for %s: %v", existing.ID, err) + } + } emitAudit(h.auditService, r, audit.EventUpdate, "User", existing.ID, "Update", true, map[string]any{"username": existing.Username}, "") - WriteJSON(w, http.StatusOK, userToResponse(existing)) + resp := userToResponse(existing) + if names, err := h.repo.GetRoleNames(existing.ID); err == nil { + resp.Roles = names + } + WriteJSON(w, http.StatusOK, resp) } // Delete handles DELETE /api/v1/users/{id} diff --git a/backend/internal/repository/users.go b/backend/internal/repository/users.go index 0436909..e5e04b1 100644 --- a/backend/internal/repository/users.go +++ b/backend/internal/repository/users.go @@ -19,6 +19,88 @@ func NewUserRepository(db *sql.DB) *UserRepository { return &UserRepository{db: db} } +// ListRoles returns all defined roles, ordered by their privilege tier. +func (r *UserRepository) ListRoles() ([]models.Role, error) { + rows, err := r.db.Query(` + SELECT id, name, description, is_system_role, created_utc, updated_utc + FROM roles + ORDER BY CASE name + WHEN 'SuperAdmin' THEN 0 WHEN 'Admin' THEN 1 + WHEN 'Operator' THEN 2 WHEN 'Viewer' THEN 3 ELSE 4 END, name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var roles []models.Role + for rows.Next() { + var role models.Role + var isSystem int + var createdStr, updatedStr string + if err := rows.Scan(&role.ID, &role.Name, &role.Description, &isSystem, &createdStr, &updatedStr); err != nil { + return nil, err + } + role.IsSystemRole = isSystem != 0 + role.CreatedUTC = parseTimeOrZero(createdStr) + role.UpdatedUTC = parseTimeOrZero(updatedStr) + roles = append(roles, role) + } + return roles, rows.Err() +} + +// GetRoleNames returns the names of the roles assigned to a user. +func (r *UserRepository) GetRoleNames(userID string) ([]string, error) { + rows, err := r.db.Query(` + SELECT ro.name FROM user_roles ur + JOIN roles ro ON ro.id = ur.role_id + WHERE ur.user_id = ? + ORDER BY ro.name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var names []string + for rows.Next() { + var n string + if err := rows.Scan(&n); err != nil { + return nil, err + } + names = append(names, n) + } + return names, rows.Err() +} + +// SetRoles replaces a user's role assignments with the named roles (unknown +// names are ignored). Runs in a transaction so the set change is atomic. +func (r *UserRepository) SetRoles(userID string, roleNames []string) error { + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec(`DELETE FROM user_roles WHERE user_id = ?`, userID); err != nil { + return err + } + now := time.Now().UTC().Format(time.RFC3339) + for _, name := range roleNames { + var roleID string + err := tx.QueryRow(`SELECT id FROM roles WHERE name = ?`, name).Scan(&roleID) + if err == sql.ErrNoRows { + continue + } + if err != nil { + return err + } + if _, err := tx.Exec(`INSERT OR IGNORE INTO user_roles (user_id, role_id, created_utc) VALUES (?, ?, ?)`, + userID, roleID, now); err != nil { + return err + } + } + return tx.Commit() +} + // Create creates a new user func (r *UserRepository) Create(user *models.User) error { if user.ID == "" { diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index d106acb..0d6168d 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -175,6 +175,9 @@ func (s *Server) setupRoutes() { r.Delete("/{id}", usersHandler.Delete) }) + // Assignable roles (built-in RBAC roles) + r.Get("/roles", usersHandler.ListRoles) + // Credentials credentialsHandler := api.NewCredentialsHandler(s.deps.CredService, s.deps.AuditService, s.logger) r.Route("/credentials", func(r chi.Router) { diff --git a/frontend/src/app/(app)/security/page.tsx b/frontend/src/app/(app)/security/page.tsx index 8d66918..e263f87 100644 --- a/frontend/src/app/(app)/security/page.tsx +++ b/frontend/src/app/(app)/security/page.tsx @@ -8,6 +8,9 @@ import CircularProgress from "@mui/material/CircularProgress"; import Divider from "@mui/material/Divider"; import FormControlLabel from "@mui/material/FormControlLabel"; import Grid from "@mui/material/Grid"; +import IconButton from "@mui/material/IconButton"; +import InputAdornment from "@mui/material/InputAdornment"; +import MenuItem from "@mui/material/MenuItem"; import Radio from "@mui/material/Radio"; import RadioGroup from "@mui/material/RadioGroup"; import Stack from "@mui/material/Stack"; @@ -18,14 +21,16 @@ import TableCell from "@mui/material/TableCell"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { useCallback, useEffect, useState } from "react"; import PageContainer from "@/app/components/container/PageContainer"; import DashboardCard from "@/app/components/shared/DashboardCard"; import { ApiError } from "@/lib/api/client"; -import { OidcApi, TlsApi } from "@/lib/api/resources"; -import type { OidcConfig, TlsStatus, WindowsStoreCert } from "@/lib/api/types"; +import { OidcApi, RolesApi, TlsApi } from "@/lib/api/resources"; +import type { OidcConfig, Role, TlsStatus, WindowsStoreCert } from "@/lib/api/types"; import { formatDateTime } from "@/lib/format"; export default function SecurityPage() { @@ -56,11 +61,19 @@ const emptyOidc: OidcConfig = { function OidcCard() { const [cfg, setCfg] = useState(emptyOidc); + const [roles, setRoles] = useState([]); const [secretDirty, setSecretDirty] = useState(false); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); + const [copied, setCopied] = useState(false); + + // The callback/redirect URI to register at the identity provider. It is + // derived from the URL the browser is using (which matches the public URL + // behind a proxy), so it stays correct without manual configuration. + const callbackUrl = + typeof window !== "undefined" ? `${window.location.origin}/api/v1/auth/oidc/callback` : ""; const load = useCallback(async () => { setLoading(true); @@ -75,8 +88,19 @@ function OidcCard() { }, []); useEffect(() => { load(); + RolesApi.list().then(setRoles).catch(() => setRoles([])); }, [load]); + const copyCallback = async () => { + try { + await navigator.clipboard.writeText(callbackUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + /* clipboard unavailable */ + } + }; + const set = (patch: Partial) => { setSaved(false); setCfg((c) => ({ ...c, ...patch })); @@ -91,7 +115,6 @@ function OidcCard() { enabled: cfg.enabled, issuer: cfg.issuer, clientId: cfg.clientId, - redirectUrl: cfg.redirectUrl, scopes: cfg.scopes, usernameClaim: cfg.usernameClaim, emailClaim: cfg.emailClaim, @@ -132,12 +155,18 @@ function OidcCard() { /> - set({ issuer: e.target.value })} /> - set({ defaultRole: e.target.value })} /> + set({ defaultRole: e.target.value })}> + None + {roles.map((r) => {r.name})} + { setSecretDirty(true); set({ clientSecret: e.target.value }); }} /> - set({ redirectUrl: e.target.value })} /> + + + + + + + + ), + }} /> (EMPTY); const [user, setUser] = useState(null); + const [roles, setRoles] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -53,6 +57,7 @@ export default function UserFormDialog({ open, userId, onClose, onSaved }: Props setError(null); setForm(EMPTY); setUser(null); + RolesApi.list().then(setRoles).catch(() => setRoles([])); if (!userId) return; setLoading(true); UsersApi.get(userId) @@ -64,6 +69,7 @@ export default function UserFormDialog({ open, userId, onClose, onSaved }: Props email: u.email ?? "", password: "", isActive: u.isActive, + roles: u.roles ?? [], }); }) .catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load user")) @@ -83,6 +89,7 @@ export default function UserFormDialog({ open, userId, onClose, onSaved }: Props email: form.email.trim() || undefined, isActive: form.isActive, password: form.password || undefined, + roles: form.roles, }); } else { await UsersApi.create({ @@ -90,6 +97,7 @@ export default function UserFormDialog({ open, userId, onClose, onSaved }: Props password: form.password, displayName: form.displayName.trim() || undefined, email: form.email.trim() || undefined, + roles: form.roles, }); } await onSaved(); @@ -137,6 +145,17 @@ export default function UserFormDialog({ open, userId, onClose, onSaved }: Props onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} /> )} + r.name)} + value={form.roles} + onChange={(_, v) => setForm((f) => ({ ...f, roles: v }))} + renderInput={(params) => ( + + )} + /> setForm((f) => ({ ...f, isActive: v }))} />} label="Active" diff --git a/frontend/src/app/(app)/users/page.tsx b/frontend/src/app/(app)/users/page.tsx index 3aa8212..6701a47 100644 --- a/frontend/src/app/(app)/users/page.tsx +++ b/frontend/src/app/(app)/users/page.tsx @@ -96,6 +96,7 @@ export default function UsersPage() { Username Display Name Email + Roles Source Last Login Active @@ -112,6 +113,13 @@ export default function UsersPage() { {u.displayName ?? "-"} {u.email ?? "-"} + + + {(u.roles ?? []).length > 0 + ? u.roles.map((r) => ) + : } + + getList("/api/v1/users", params), get: (id: string) => api.get(`/api/v1/users/${id}`).then((r) => r.data), - create: (body: { username: string; password: string; displayName?: string; email?: string }) => + create: (body: { username: string; password: string; displayName?: string; email?: string; roles?: string[] }) => api.post("/api/v1/users", body).then((r) => r.data), - update: (id: string, body: Partial & { password?: string }) => + update: (id: string, body: Partial & { password?: string; roles?: string[] }) => api.put(`/api/v1/users/${id}`, body).then((r) => r.data), remove: (id: string) => api.delete<{ deleted: boolean }>(`/api/v1/users/${id}`).then((r) => r.data), }; +export const RolesApi = { + list: () => api.get("/api/v1/roles").then((r) => r.data ?? []), +}; + export const ApiKeysApi = { list: (params?: PageParams & { userId?: string }) => api.get("/api/v1/api-keys", { query: params as Record }).then((r) => r.data ?? []), diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 6d7614d..ebc315e 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -213,11 +213,17 @@ export interface User { displayName?: string; isActive: boolean; isOidcUser: boolean; + roles: string[]; lastLoginUtc?: string; createdAt: string; updatedAt: string; } +export interface Role { + name: string; + description?: string; +} + export interface CountPair { total: number; enabled: number; -- 2.52.0 From b2218f6be4e380027c32e9f5c994c75a34fc6019 Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 21:32:51 -0400 Subject: [PATCH 05/11] =?UTF-8?q?feat(ui):=20Object=20Viewer=20=E2=80=94?= =?UTF-8?q?=20browse=20&=20inspect=20directory=20objects=20to=20build=20fi?= =?UTF-8?q?lters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../layout/horizontal/navbar/Menudata.ts | 1 + .../layout/vertical/sidebar/MenuItems.ts | 7 + .../app/(app)/objects/ObjectDetailDialog.tsx | 120 ++++++++++ frontend/src/app/(app)/objects/page.tsx | 209 ++++++++++++++++++ frontend/src/lib/api/resources.ts | 3 +- frontend/src/lib/api/types.ts | 12 + 6 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/(app)/objects/ObjectDetailDialog.tsx create mode 100644 frontend/src/app/(app)/objects/page.tsx diff --git a/frontend/src/app/(app)/layout/horizontal/navbar/Menudata.ts b/frontend/src/app/(app)/layout/horizontal/navbar/Menudata.ts index 0b3d219..1776385 100644 --- a/frontend/src/app/(app)/layout/horizontal/navbar/Menudata.ts +++ b/frontend/src/app/(app)/layout/horizontal/navbar/Menudata.ts @@ -29,6 +29,7 @@ const Menuitems = [ bgcolor: 'warning', children: [ { id: uniqueId(), title: 'Connections', href: '/connections' }, + { id: uniqueId(), title: 'Object Viewer', href: '/objects' }, { id: uniqueId(), title: 'Credentials', href: '/credentials' }, ], }, diff --git a/frontend/src/app/(app)/layout/vertical/sidebar/MenuItems.ts b/frontend/src/app/(app)/layout/vertical/sidebar/MenuItems.ts index c123e1d..dcc2818 100644 --- a/frontend/src/app/(app)/layout/vertical/sidebar/MenuItems.ts +++ b/frontend/src/app/(app)/layout/vertical/sidebar/MenuItems.ts @@ -56,6 +56,13 @@ const Menuitems: NavGroup[] = [ href: "/connections", bgcolor: "warning", }, + { + id: uniqueId(), + title: "Object Viewer", + icon: "magnifer-line-duotone", + href: "/objects", + bgcolor: "secondary", + }, { id: uniqueId(), title: "Credentials", diff --git a/frontend/src/app/(app)/objects/ObjectDetailDialog.tsx b/frontend/src/app/(app)/objects/ObjectDetailDialog.tsx new file mode 100644 index 0000000..2664ae3 --- /dev/null +++ b/frontend/src/app/(app)/objects/ObjectDetailDialog.tsx @@ -0,0 +1,120 @@ +"use client"; + +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import CircularProgress from "@mui/material/CircularProgress"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { useEffect, useMemo, useState } from "react"; + +import { ApiError } from "@/lib/api/client"; +import { ConnectionsApi } from "@/lib/api/resources"; + +interface Props { + connectionId: string; + dn: string | null; + onClose: () => void; +} + +// ObjectDetailDialog shows every attribute of a single directory object so the +// operator can inspect real values and copy attribute names/values into a rule +// filter. +export default function ObjectDetailDialog({ connectionId, dn, onClose }: Props) { + const [attrs, setAttrs] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(""); + + useEffect(() => { + if (!dn) return; + setAttrs(null); + setError(null); + setFilter(""); + setLoading(true); + ConnectionsApi.queryPreview(connectionId, { + baseDn: dn, + scope: "base", + filter: "(objectClass=*)", + attributes: ["*"], + limit: 1, + }) + .then((res) => setAttrs(res.entries[0]?.attributes ?? {})) + .catch((err) => setError(err instanceof ApiError ? err.message : "Failed to load object")) + .finally(() => setLoading(false)); + }, [connectionId, dn]); + + const rows = useMemo(() => { + if (!attrs) return []; + const f = filter.trim().toLowerCase(); + return Object.entries(attrs) + .filter(([name]) => !f || name.toLowerCase().includes(f)) + .sort((a, b) => a[0].localeCompare(b[0])); + }, [attrs, filter]); + + const copy = (text: string) => { void navigator.clipboard?.writeText(text).catch(() => {}); }; + + return ( + + {dn} + + {error && {error}} + {loading ? ( + + ) : ( + <> + setFilter(e.target.value)} sx={{ mb: 2 }} placeholder="e.g. department" /> + + + + + Attribute + Value + + + + + {rows.map(([name, values]) => ( + + + {name} + + + + {values.map((v, i) => {v})} + + + + + copy(name)}> + + + copy(values.join("; "))}> + + + + ))} + {rows.length === 0 && ( + No attributes. + )} + +
+
+ + )} +
+
+ ); +} diff --git a/frontend/src/app/(app)/objects/page.tsx b/frontend/src/app/(app)/objects/page.tsx new file mode 100644 index 0000000..d138790 --- /dev/null +++ b/frontend/src/app/(app)/objects/page.tsx @@ -0,0 +1,209 @@ +"use client"; + +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; +import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; +import MenuItem from "@mui/material/MenuItem"; +import Stack from "@mui/material/Stack"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import SearchOutlinedIcon from "@mui/icons-material/SearchOutlined"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import { useEffect, useState } from "react"; + +import PageContainer from "@/app/components/container/PageContainer"; +import DashboardCard from "@/app/components/shared/DashboardCard"; +import { ApiError } from "@/lib/api/client"; +import { ConnectionsApi } from "@/lib/api/resources"; +import type { ADConnection, QueryPreviewEntry } from "@/lib/api/types"; + +import DirectoryPicker from "../rules/DirectoryPicker"; +import ObjectDetailDialog from "./ObjectDetailDialog"; + +const OBJECT_TYPES = [ + { value: "User", label: "Users", filter: "(&(objectCategory=person)(objectClass=user))" }, + { value: "Computer", label: "Computers", filter: "(objectClass=computer)" }, + { value: "Group", label: "Groups", filter: "(objectClass=group)" }, + { value: "OU", label: "Organizational Units", filter: "(objectClass=organizationalUnit)" }, + { value: "Any", label: "Any object", filter: "(objectClass=*)" }, +]; + +const LIST_ATTRS = ["cn", "name", "distinguishedName", "sAMAccountName", "objectClass", "canonicalName", "mail", "description", "userPrincipalName"]; + +// Escape RFC 4515 specials in a user-typed search term (we add our own * wildcards). +function esc(s: string): string { + return s.replace(/\\/g, "\\5c").replace(/\*/g, "\\2a").replace(/\(/g, "\\28").replace(/\)/g, "\\29").replace(/\0/g, "\\00"); +} + +function firstAttr(e: QueryPreviewEntry, ...names: string[]): string { + for (const n of names) { + const v = e.attributes[n]; + if (v && v.length) return v[0]; + } + return ""; +} + +function objectKind(e: QueryPreviewEntry): string { + const classes = (e.attributes.objectClass ?? []).map((c) => c.toLowerCase()); + if (classes.includes("computer")) return "Computer"; + if (classes.includes("group")) return "Group"; + if (classes.includes("organizationalunit")) return "OU"; + if (classes.includes("user")) return "User"; + return classes[classes.length - 1] ?? "object"; +} + +// The Object Viewer lets an operator browse a directory — pick a connection, +// search by name, and drill into any object's attributes — to discover the +// attributes and values worth putting in a rule filter. +export default function ObjectViewerPage() { + const [connections, setConnections] = useState([]); + const [connectionId, setConnectionId] = useState(""); + const [objectType, setObjectType] = useState("User"); + const [q, setQ] = useState(""); + const [baseDn, setBaseDn] = useState(""); + const [rawFilter, setRawFilter] = useState(""); + + const [entries, setEntries] = useState([]); + const [truncated, setTruncated] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [searched, setSearched] = useState(false); + const [detailDn, setDetailDn] = useState(null); + + useEffect(() => { + ConnectionsApi.list({ pageSize: 200 }) + .then((r) => { + setConnections(r.items); + if (r.items.length === 1) setConnectionId(r.items[0].id); + }) + .catch(() => setConnections([])); + }, []); + + const buildFilter = (): string => { + if (rawFilter.trim()) return rawFilter.trim(); + const base = OBJECT_TYPES.find((t) => t.value === objectType)?.filter ?? "(objectClass=*)"; + const term = q.trim(); + if (!term) return base; + const e = esc(term); + const name = `(|(cn=*${e}*)(sAMAccountName=*${e}*)(displayName=*${e}*)(name=*${e}*)(mail=*${e}*))`; + return `(&${base}${name})`; + }; + + const search = async () => { + if (!connectionId) { setError("Select a connection first"); return; } + setLoading(true); + setError(null); + setSearched(true); + try { + const res = await ConnectionsApi.queryPreview(connectionId, { + baseDn: baseDn || undefined, + scope: "subtree", + filter: buildFilter(), + attributes: LIST_ATTRS, + limit: 200, + }); + setEntries(res.entries); + setTruncated(res.truncated); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Search failed"); + setEntries([]); + } finally { + setLoading(false); + } + }; + + return ( + + + + {error && setError(null)}>{error}} + + + + setConnectionId(e.target.value)}> + Select… + {connections.map((c) => {c.name})} + + setObjectType(e.target.value)} disabled={!!rawFilter.trim()}> + {OBJECT_TYPES.map((t) => {t.label})} + + setQ(e.target.value)} disabled={!!rawFilter.trim()} + placeholder="name, logon, email…" + onKeyDown={(e) => { if (e.key === "Enter") void search(); }} /> + + + + + setRawFilter(e.target.value)} + placeholder="(&(objectClass=user)(department=Sales))" + slotProps={{ htmlInput: { style: { fontFamily: "monospace", fontSize: 12 } } }} /> + + + + + + {loading ? ( + + ) : searched && entries.length === 0 ? ( + No objects matched. + ) : entries.length > 0 ? ( + <> + + {entries.length} object(s){truncated ? " (truncated — narrow your search)" : ""} + + + + + + Name + Type + Canonical / DN + Attributes + + + + {entries.map((e) => ( + setDetailDn(e.dn)}> + {firstAttr(e, "cn", "name", "sAMAccountName") || e.dn} + + + {firstAttr(e, "canonicalName") || e.dn} + + + + { ev.stopPropagation(); setDetailDn(e.dn); }}> + + + + + + ))} + +
+
+ + ) : null} +
+
+ + setDetailDn(null)} /> +
+ ); +} diff --git a/frontend/src/lib/api/resources.ts b/frontend/src/lib/api/resources.ts index 99294a8..2df5721 100644 --- a/frontend/src/lib/api/resources.ts +++ b/frontend/src/lib/api/resources.ts @@ -26,6 +26,7 @@ import { RuleSummary, DirectoryObject, AttributeInfo, + QueryPreviewResult, Role, Schedule, User, @@ -92,7 +93,7 @@ export const ConnectionsApi = { test: (id: string) => api.post(`/api/v1/ad-connections/${id}/test`).then((r) => r.data), queryPreview: (id: string, body: Record) => - api.post(`/api/v1/ad-connections/${id}/query-preview`, body).then((r) => r.data), + api.post(`/api/v1/ad-connections/${id}/query-preview`, body).then((r) => r.data), directory: (id: string, type: string, q: string, limit = 25) => api .get(`/api/v1/ad-connections/${id}/directory`, { query: { type, q, limit } }) diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index ebc315e..e906c44 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -421,6 +421,18 @@ export interface AttributeInfo { description?: string; } +// Ad-hoc LDAP query results (object viewer / query preview). +export interface QueryPreviewEntry { + dn: string; + attributes: Record; +} + +export interface QueryPreviewResult { + entries: QueryPreviewEntry[]; + truncated: boolean; + count: number; +} + // The full create/update payload the editor sends (superset of Rule scalars // plus the authored filter and actions). export interface RuleConditionInput { -- 2.52.0 From 27ff00d75cb8f75908478c2cf02d5f2e13be7717 Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 21:36:19 -0400 Subject: [PATCH 06/11] 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 --- backend/internal/tlsmgr/manager.go | 9 +++++ backend/internal/tlsmgr/store_other.go | 3 ++ backend/internal/tlsmgr/store_windows.go | 51 ++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/backend/internal/tlsmgr/manager.go b/backend/internal/tlsmgr/manager.go index c1fbdff..e5c57b7 100644 --- a/backend/internal/tlsmgr/manager.go +++ b/backend/internal/tlsmgr/manager.go @@ -200,6 +200,15 @@ func (m *Manager) ensureAuto(pfxPassword string) error { PrivateKey: chain.Leaf.Key, Leaf: chain.Leaf.Certificate, }) + + // Install (or refresh) the self-managed root and intermediate into the host + // trust stores so local clients trust the served chain. Best-effort on + // Windows (needs admin); a no-op elsewhere. + if err := InstallTrustAnchors(chain.Root.DER, chain.Intermediate.DER); err != nil { + m.logf("could not install CA into the system trust store (%v); TLS still works, but clients on this host may not trust it", err) + } else { + m.logf("Self-managed CA present in the system trust store") + } return nil } diff --git a/backend/internal/tlsmgr/store_other.go b/backend/internal/tlsmgr/store_other.go index 61a3616..d2163b8 100644 --- a/backend/internal/tlsmgr/store_other.go +++ b/backend/internal/tlsmgr/store_other.go @@ -15,3 +15,6 @@ func ListWindowsCerts() ([]StoreCert, error) { return nil, errWindowsOnly } // WindowsStoreSupported reports whether the Windows store is usable here. func WindowsStoreSupported() bool { return false } + +// InstallTrustAnchors is a no-op off Windows (no system trust store to manage). +func InstallTrustAnchors(rootDER, interDER []byte) error { return nil } diff --git a/backend/internal/tlsmgr/store_windows.go b/backend/internal/tlsmgr/store_windows.go index 331b579..7c3fc1f 100644 --- a/backend/internal/tlsmgr/store_windows.go +++ b/backend/internal/tlsmgr/store_windows.go @@ -46,8 +46,59 @@ var ( procCertGetCertificateContextProperty = crypt32.NewProc("CertGetCertificateContextProperty") procNCryptSignHash = ncrypt.NewProc("NCryptSignHash") procNCryptFreeObject = ncrypt.NewProc("NCryptFreeObject") + procCertAddEncodedCertToStore = crypt32.NewProc("CertAddEncodedCertificateToStore") ) +const ( + x509ASNEncoding = 0x00000001 + certStoreAddReplaceExisting = 3 +) + +// InstallTrustAnchors installs the self-managed root and intermediate into the +// LocalMachine "Root" (Trusted Root CAs) and "CA" (Intermediate CAs) stores so +// clients on this host trust the served chain. Safe to call repeatedly — the +// replace disposition makes re-adding an unchanged certificate a no-op and a +// renewed CA supersedes the previous one. Requires administrative rights +// (the Windows service runs as LocalSystem, which has them). +func InstallTrustAnchors(rootDER, interDER []byte) error { + if err := addToSystemStore("ROOT", rootDER); err != nil { + return fmt.Errorf("installing root CA: %w", err) + } + if err := addToSystemStore("CA", interDER); err != nil { + return fmt.Errorf("installing intermediate CA: %w", err) + } + return nil +} + +func addToSystemStore(name string, der []byte) error { + if len(der) == 0 { + return nil + } + namePtr, err := windows.UTF16PtrFromString(name) + if err != nil { + return err + } + // Open the LocalMachine system store read-write (no readonly flag). + store, err := windows.CertOpenStore( + certStoreProvSystemW, 0, 0, certSystemStoreLocalMac, + uintptr(unsafe.Pointer(namePtr)), + ) + if err != nil { + return fmt.Errorf("opening %s store: %w", name, err) + } + defer windows.CertCloseStore(store, 0) + + r, _, e := procCertAddEncodedCertToStore.Call( + uintptr(store), x509ASNEncoding, + uintptr(unsafe.Pointer(&der[0])), uintptr(len(der)), + certStoreAddReplaceExisting, 0, + ) + if r == 0 { + return fmt.Errorf("adding to %s store: %v", name, e) + } + return nil +} + // prevKey holds the NCrypt key handle currently in use so it can be released // when the manager reloads to a new certificate. var ( -- 2.52.0 From 2befe64a0c832998fe2ff43f92e4dd19819993f1 Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 21:46:08 -0400 Subject: [PATCH 07/11] 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 --- backend/internal/cli/firewall_other.go | 10 + backend/internal/cli/firewall_windows.go | 51 ++ backend/internal/cli/service.go | 17 + installer/OrchestrAD.wxs | 11 +- installer/license.rtf | 687 +---------------------- 5 files changed, 97 insertions(+), 679 deletions(-) create mode 100644 backend/internal/cli/firewall_other.go create mode 100644 backend/internal/cli/firewall_windows.go diff --git a/backend/internal/cli/firewall_other.go b/backend/internal/cli/firewall_other.go new file mode 100644 index 0000000..ba0e1a1 --- /dev/null +++ b/backend/internal/cli/firewall_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package cli + +// EnsureFirewallRule is a no-op off Windows (firewall management is handled by +// the platform's own tooling, e.g. firewalld/ufw/iptables). +func EnsureFirewallRule(port int) error { return nil } + +// RemoveFirewallRule is a no-op off Windows. +func RemoveFirewallRule() error { return nil } diff --git a/backend/internal/cli/firewall_windows.go b/backend/internal/cli/firewall_windows.go new file mode 100644 index 0000000..c50e80f --- /dev/null +++ b/backend/internal/cli/firewall_windows.go @@ -0,0 +1,51 @@ +//go:build windows + +package cli + +import ( + "fmt" + "os/exec" + "strconv" + + "github.com/Grace-Solutions/OrchestrAD/internal/logging" +) + +const firewallRuleName = "OrchestrAD" + +// firewallRemoteIP scopes the inbound rule to RFC 1918 private ranges plus the +// carrier-grade NAT range (100.64.0.0/10). Loopback is exempt from the firewall +// so localhost is unaffected. +const firewallRemoteIP = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,100.64.0.0/10" + +// EnsureFirewallRule idempotently creates the inbound allow rule for the service +// port. It first deletes any rule of the same name so the rule always reflects +// the current port, then adds it. Requires administrative rights (the MSI custom +// action and the service run elevated). +func EnsureFirewallRule(port int) error { + // Best-effort delete of a prior rule (ignore "no rules match"). + _ = exec.Command("netsh", "advfirewall", "firewall", "delete", "rule", + "name="+firewallRuleName).Run() + + out, err := exec.Command("netsh", "advfirewall", "firewall", "add", "rule", + "name="+firewallRuleName, + "dir=in", "action=allow", "protocol=TCP", + "localport="+strconv.Itoa(port), + "remoteip="+firewallRemoteIP, + "profile=any", + "description=OrchestrAD inbound (RFC1918 + CGNAT)", + ).CombinedOutput() + if err != nil { + return fmt.Errorf("netsh add rule failed: %v: %s", err, out) + } + logging.Info("Firewall", "Inbound rule '%s' allows TCP %d from %s", firewallRuleName, port, firewallRemoteIP) + return nil +} + +// RemoveFirewallRule idempotently deletes the inbound rule. A missing rule is +// not an error. +func RemoveFirewallRule() error { + _ = exec.Command("netsh", "advfirewall", "firewall", "delete", "rule", + "name="+firewallRuleName).Run() + logging.Info("Firewall", "Inbound rule '%s' removed", firewallRuleName) + return nil +} diff --git a/backend/internal/cli/service.go b/backend/internal/cli/service.go index 175c7a3..e81b702 100644 --- a/backend/internal/cli/service.go +++ b/backend/internal/cli/service.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" + "github.com/Grace-Solutions/OrchestrAD/internal/config" "github.com/Grace-Solutions/OrchestrAD/internal/logging" "github.com/kardianos/service" ) @@ -143,6 +144,8 @@ func initializeService() error { logging.Info("Service", "Service already installed") } + ensureFirewall() + if status == service.StatusRunning { logging.Info("Service", "Service already running") return nil @@ -154,6 +157,19 @@ func initializeService() error { return nil } +// ensureFirewall creates the inbound allow rule for the configured listen port. +// Best-effort: a failure (e.g. missing privileges) is logged but does not block +// service provisioning. +func ensureFirewall() { + port := 18090 + if cfg, err := config.Load(); err == nil { + port = cfg.Server.Port + } + if err := EnsureFirewallRule(port); err != nil { + logging.Warn("Firewall", "Could not create inbound firewall rule (%v); open TCP %d manually if needed", err, port) + } +} + // removeService idempotently ensures the service is stopped and removed. // Re-running it is safe: a not-installed service is treated as already removed, // and a stop failure on an already-stopped service does not block removal. This @@ -182,6 +198,7 @@ func removeService() error { if err := s.Uninstall(); err != nil { return fmt.Errorf("removing service: %w", err) } + _ = RemoveFirewallRule() logging.Info("Service", "Service removed") return nil } diff --git a/installer/OrchestrAD.wxs b/installer/OrchestrAD.wxs index b1c5e82..5a62a4b 100644 --- a/installer/OrchestrAD.wxs +++ b/installer/OrchestrAD.wxs @@ -2,7 +2,7 @@ + @@ -132,6 +134,11 @@ + + + + diff --git a/installer/license.rtf b/installer/license.rtf index abb2c18..13798cf 100644 --- a/installer/license.rtf +++ b/installer/license.rtf @@ -1,677 +1,10 @@ -{ tf1ansideff0{ onttbl{ 0 Consolas;}} s16 -GNU GENERAL PUBLIC LICENSEpar - Version 3, 29 June 2007par -par - Copyright (C) 2007 Free Software Foundation, Inc. par - Everyone is permitted to copy and distribute verbatim copiespar - of this license document, but changing it is not allowed.par -par - Preamblepar -par - The GNU General Public License is a free, copyleft license forpar -software and other kinds of works.par -par - The licenses for most software and other practical works are designedpar -to take away your freedom to share and change the works. By contrast,par -the GNU General Public License is intended to guarantee your freedom topar -share and change all versions of a program--to make sure it remains freepar -software for all its users. We, the Free Software Foundation, use thepar -GNU General Public License for most of our software; it applies also topar -any other work released this way by its authors. You can apply it topar -your programs, too.par -par - When we speak of free software, we are referring to freedom, notpar -price. Our General Public Licenses are designed to make sure that youpar -have the freedom to distribute copies of free software (and charge forpar -them if you wish), that you receive source code or can get it if youpar -want it, that you can change the software or use pieces of it in newpar -free programs, and that you know you can do these things.par -par - To protect your rights, we need to prevent others from denying youpar -these rights or asking you to surrender the rights. Therefore, you havepar -certain responsibilities if you distribute copies of the software, or ifpar -you modify it: responsibilities to respect the freedom of others.par -par - For example, if you distribute copies of such a program, whetherpar -gratis or for a fee, you must pass on to the recipients the samepar -freedoms that you received. You must make sure that they, too, receivepar -or can get the source code. And you must show them these terms so theypar -know their rights.par -par - Developers that use the GNU GPL protect your rights with two steps:par -(1) assert copyright on the software, and (2) offer you this Licensepar -giving you legal permission to copy, distribute and/or modify it.par -par - For the developers' and authors' protection, the GPL clearly explainspar -that there is no warranty for this free software. For both users' andpar -authors' sake, the GPL requires that modified versions be marked aspar -changed, so that their problems will not be attributed erroneously topar -authors of previous versions.par -par - Some devices are designed to deny users access to install or runpar -modified versions of the software inside them, although the manufacturerpar -can do so. This is fundamentally incompatible with the aim ofpar -protecting users' freedom to change the software. The systematicpar -pattern of such abuse occurs in the area of products for individuals topar -use, which is precisely where it is most unacceptable. Therefore, wepar -have designed this version of the GPL to prohibit the practice for thosepar -products. If such problems arise substantially in other domains, wepar -stand ready to extend this provision to those domains in future versionspar -of the GPL, as needed to protect the freedom of users.par -par - Finally, every program is threatened constantly by software patents.par -States should not allow patents to restrict development and use ofpar -software on general-purpose computers, but in those that do, we wish topar -avoid the special danger that patents applied to a free program couldpar -make it effectively proprietary. To prevent this, the GPL assures thatpar -patents cannot be used to render the program non-free.par -par - The precise terms and conditions for copying, distribution andpar -modification follow.par -par - TERMS AND CONDITIONSpar -par - 0. Definitions.par -par - "This License" refers to version 3 of the GNU General Public License.par -par - "Copyright" also means copyright-like laws that apply to other kinds ofpar -works, such as semiconductor masks.par -par - "The Program" refers to any copyrightable work licensed under thispar -License. Each licensee is addressed as "you". "Licensees" andpar -"recipients" may be individuals or organizations.par -par - To "modify" a work means to copy from or adapt all or part of the workpar -in a fashion requiring copyright permission, other than the making of anpar -exact copy. The resulting work is called a "modified version" of thepar -earlier work or a work "based on" the earlier work.par -par - A "covered work" means either the unmodified Program or a work basedpar -on the Program.par -par - To "propagate" a work means to do anything with it that, withoutpar -permission, would make you directly or secondarily liable forpar -infringement under applicable copyright law, except executing it on apar -computer or modifying a private copy. Propagation includes copying,par -distribution (with or without modification), making available to thepar -public, and in some countries other activities as well.par -par - To "convey" a work means any kind of propagation that enables otherpar -parties to make or receive copies. Mere interaction with a user throughpar -a computer network, with no transfer of a copy, is not conveying.par -par - An interactive user interface displays "Appropriate Legal Notices"par -to the extent that it includes a convenient and prominently visiblepar -feature that (1) displays an appropriate copyright notice, and (2)par -tells the user that there is no warranty for the work (except to thepar -extent that warranties are provided), that licensees may convey thepar -work under this License, and how to view a copy of this License. Ifpar -the interface presents a list of user commands or options, such as apar -menu, a prominent item in the list meets this criterion.par -par - 1. Source Code.par -par - The "source code" for a work means the preferred form of the workpar -for making modifications to it. "Object code" means any non-sourcepar -form of a work.par -par - A "Standard Interface" means an interface that either is an officialpar -standard defined by a recognized standards body, or, in the case ofpar -interfaces specified for a particular programming language, one thatpar -is widely used among developers working in that language.par -par - The "System Libraries" of an executable work include anything, otherpar -than the work as a whole, that (a) is included in the normal form ofpar -packaging a Major Component, but which is not part of that Majorpar -Component, and (b) serves only to enable use of the work with thatpar -Major Component, or to implement a Standard Interface for which anpar -implementation is available to the public in source code form. Apar -"Major Component", in this context, means a major essential componentpar -(kernel, window system, and so on) of the specific operating systempar -(if any) on which the executable work runs, or a compiler used topar -produce the work, or an object code interpreter used to run it.par -par - The "Corresponding Source" for a work in object code form means allpar -the source code needed to generate, install, and (for an executablepar -work) run the object code and to modify the work, including scripts topar -control those activities. However, it does not include the work'spar -System Libraries, or general-purpose tools or generally available freepar -programs which are used unmodified in performing those activities butpar -which are not part of the work. For example, Corresponding Sourcepar -includes interface definition files associated with source files forpar -the work, and the source code for shared libraries and dynamicallypar -linked subprograms that the work is specifically designed to require,par -such as by intimate data communication or control flow between thosepar -subprograms and other parts of the work.par -par - The Corresponding Source need not include anything that userspar -can regenerate automatically from other parts of the Correspondingpar -Source.par -par - The Corresponding Source for a work in source code form is thatpar -same work.par -par - 2. Basic Permissions.par -par - All rights granted under this License are granted for the term ofpar -copyright on the Program, and are irrevocable provided the statedpar -conditions are met. This License explicitly affirms your unlimitedpar -permission to run the unmodified Program. The output from running apar -covered work is covered by this License only if the output, given itspar -content, constitutes a covered work. This License acknowledges yourpar -rights of fair use or other equivalent, as provided by copyright law.par -par - You may make, run and propagate covered works that you do notpar -convey, without conditions so long as your license otherwise remainspar -in force. You may convey covered works to others for the sole purposepar -of having them make modifications exclusively for you, or provide youpar -with facilities for running those works, provided that you comply withpar -the terms of this License in conveying all material for which you dopar -not control copyright. Those thus making or running the covered workspar -for you must do so exclusively on your behalf, under your directionpar -and control, on terms that prohibit them from making any copies ofpar -your copyrighted material outside their relationship with you.par -par - Conveying under any other circumstances is permitted solely underpar -the conditions stated below. Sublicensing is not allowed; section 10par -makes it unnecessary.par -par - 3. Protecting Users' Legal Rights From Anti-Circumvention Law.par -par - No covered work shall be deemed part of an effective technologicalpar -measure under any applicable law fulfilling obligations under articlepar -11 of the WIPO copyright treaty adopted on 20 December 1996, orpar -similar laws prohibiting or restricting circumvention of suchpar -measures.par -par - When you convey a covered work, you waive any legal power to forbidpar -circumvention of technological measures to the extent such circumventionpar -is effected by exercising rights under this License with respect topar -the covered work, and you disclaim any intention to limit operation orpar -modification of the work as a means of enforcing, against the work'spar -users, your or third parties' legal rights to forbid circumvention ofpar -technological measures.par -par - 4. Conveying Verbatim Copies.par -par - You may convey verbatim copies of the Program's source code as youpar -receive it, in any medium, provided that you conspicuously andpar -appropriately publish on each copy an appropriate copyright notice;par -keep intact all notices stating that this License and anypar -non-permissive terms added in accord with section 7 apply to the code;par -keep intact all notices of the absence of any warranty; and give allpar -recipients a copy of this License along with the Program.par -par - You may charge any price or no price for each copy that you convey,par -and you may offer support or warranty protection for a fee.par -par - 5. Conveying Modified Source Versions.par -par - You may convey a work based on the Program, or the modifications topar -produce it from the Program, in the form of source code under thepar -terms of section 4, provided that you also meet all of these conditions:par -par - a) The work must carry prominent notices stating that you modifiedpar - it, and giving a relevant date.par -par - b) The work must carry prominent notices stating that it ispar - released under this License and any conditions added under sectionpar - 7. This requirement modifies the requirement in section 4 topar - "keep intact all notices".par -par - c) You must license the entire work, as a whole, under thispar - License to anyone who comes into possession of a copy. Thispar - License will therefore apply, along with any applicable section 7par - additional terms, to the whole of the work, and all its parts,par - regardless of how they are packaged. This License gives nopar - permission to license the work in any other way, but it does notpar - invalidate such permission if you have separately received it.par -par - d) If the work has interactive user interfaces, each must displaypar - Appropriate Legal Notices; however, if the Program has interactivepar - interfaces that do not display Appropriate Legal Notices, yourpar - work need not make them do so.par -par - A compilation of a covered work with other separate and independentpar -works, which are not by their nature extensions of the covered work,par -and which are not combined with it such as to form a larger program,par -in or on a volume of a storage or distribution medium, is called anpar -"aggregate" if the compilation and its resulting copyright are notpar -used to limit the access or legal rights of the compilation's userspar -beyond what the individual works permit. Inclusion of a covered workpar -in an aggregate does not cause this License to apply to the otherpar -parts of the aggregate.par -par - 6. Conveying Non-Source Forms.par -par - You may convey a covered work in object code form under the termspar -of sections 4 and 5, provided that you also convey thepar -machine-readable Corresponding Source under the terms of this License,par -in one of these ways:par -par - a) Convey the object code in, or embodied in, a physical productpar - (including a physical distribution medium), accompanied by thepar - Corresponding Source fixed on a durable physical mediumpar - customarily used for software interchange.par -par - b) Convey the object code in, or embodied in, a physical productpar - (including a physical distribution medium), accompanied by apar - written offer, valid for at least three years and valid for aspar - long as you offer spare parts or customer support for that productpar - model, to give anyone who possesses the object code either (1) apar - copy of the Corresponding Source for all the software in thepar - product that is covered by this License, on a durable physicalpar - medium customarily used for software interchange, for a price nopar - more than your reasonable cost of physically performing thispar - conveying of source, or (2) access to copy thepar - Corresponding Source from a network server at no charge.par -par - c) Convey individual copies of the object code with a copy of thepar - written offer to provide the Corresponding Source. Thispar - alternative is allowed only occasionally and noncommercially, andpar - only if you received the object code with such an offer, in accordpar - with subsection 6b.par -par - d) Convey the object code by offering access from a designatedpar - place (gratis or for a charge), and offer equivalent access to thepar - Corresponding Source in the same way through the same place at nopar - further charge. You need not require recipients to copy thepar - Corresponding Source along with the object code. If the place topar - copy the object code is a network server, the Corresponding Sourcepar - may be on a different server (operated by you or a third party)par - that supports equivalent copying facilities, provided you maintainpar - clear directions next to the object code saying where to find thepar - Corresponding Source. Regardless of what server hosts thepar - Corresponding Source, you remain obligated to ensure that it ispar - available for as long as needed to satisfy these requirements.par -par - e) Convey the object code using peer-to-peer transmission, providedpar - you inform other peers where the object code and Correspondingpar - Source of the work are being offered to the general public at nopar - charge under subsection 6d.par -par - A separable portion of the object code, whose source code is excludedpar -from the Corresponding Source as a System Library, need not bepar -included in conveying the object code work.par -par - A "User Product" is either (1) a "consumer product", which means anypar -tangible personal property which is normally used for personal, family,par -or household purposes, or (2) anything designed or sold for incorporationpar -into a dwelling. In determining whether a product is a consumer product,par -doubtful cases shall be resolved in favor of coverage. For a particularpar -product received by a particular user, "normally used" refers to apar -typical or common use of that class of product, regardless of the statuspar -of the particular user or of the way in which the particular userpar -actually uses, or expects or is expected to use, the product. A productpar -is a consumer product regardless of whether the product has substantialpar -commercial, industrial or non-consumer uses, unless such uses representpar -the only significant mode of use of the product.par -par - "Installation Information" for a User Product means any methods,par -procedures, authorization keys, or other information required to installpar -and execute modified versions of a covered work in that User Product frompar -a modified version of its Corresponding Source. The information mustpar -suffice to ensure that the continued functioning of the modified objectpar -code is in no case prevented or interfered with solely becausepar -modification has been made.par -par - If you convey an object code work under this section in, or with, orpar -specifically for use in, a User Product, and the conveying occurs aspar -part of a transaction in which the right of possession and use of thepar -User Product is transferred to the recipient in perpetuity or for apar -fixed term (regardless of how the transaction is characterized), thepar -Corresponding Source conveyed under this section must be accompaniedpar -by the Installation Information. But this requirement does not applypar -if neither you nor any third party retains the ability to installpar -modified object code on the User Product (for example, the work haspar -been installed in ROM).par -par - The requirement to provide Installation Information does not include apar -requirement to continue to provide support service, warranty, or updatespar -for a work that has been modified or installed by the recipient, or forpar -the User Product in which it has been modified or installed. Access to apar -network may be denied when the modification itself materially andpar -adversely affects the operation of the network or violates the rules andpar -protocols for communication across the network.par -par - Corresponding Source conveyed, and Installation Information provided,par -in accord with this section must be in a format that is publiclypar -documented (and with an implementation available to the public inpar -source code form), and must require no special password or key forpar -unpacking, reading or copying.par -par - 7. Additional Terms.par -par - "Additional permissions" are terms that supplement the terms of thispar -License by making exceptions from one or more of its conditions.par -Additional permissions that are applicable to the entire Program shallpar -be treated as though they were included in this License, to the extentpar -that they are valid under applicable law. If additional permissionspar -apply only to part of the Program, that part may be used separatelypar -under those permissions, but the entire Program remains governed bypar -this License without regard to the additional permissions.par -par - When you convey a copy of a covered work, you may at your optionpar -remove any additional permissions from that copy, or from any part ofpar -it. (Additional permissions may be written to require their ownpar -removal in certain cases when you modify the work.) You may placepar -additional permissions on material, added by you to a covered work,par -for which you have or can give appropriate copyright permission.par -par - Notwithstanding any other provision of this License, for material youpar -add to a covered work, you may (if authorized by the copyright holders ofpar -that material) supplement the terms of this License with terms:par -par - a) Disclaiming warranty or limiting liability differently from thepar - terms of sections 15 and 16 of this License; orpar -par - b) Requiring preservation of specified reasonable legal notices orpar - author attributions in that material or in the Appropriate Legalpar - Notices displayed by works containing it; orpar -par - c) Prohibiting misrepresentation of the origin of that material, orpar - requiring that modified versions of such material be marked inpar - reasonable ways as different from the original version; orpar -par - d) Limiting the use for publicity purposes of names of licensors orpar - authors of the material; orpar -par - e) Declining to grant rights under trademark law for use of somepar - trade names, trademarks, or service marks; orpar -par - f) Requiring indemnification of licensors and authors of thatpar - material by anyone who conveys the material (or modified versions ofpar - it) with contractual assumptions of liability to the recipient, forpar - any liability that these contractual assumptions directly impose onpar - those licensors and authors.par -par - All other non-permissive additional terms are considered "furtherpar -restrictions" within the meaning of section 10. If the Program as youpar -received it, or any part of it, contains a notice stating that it ispar -governed by this License along with a term that is a furtherpar -restriction, you may remove that term. If a license document containspar -a further restriction but permits relicensing or conveying under thispar -License, you may add to a covered work material governed by the termspar -of that license document, provided that the further restriction doespar -not survive such relicensing or conveying.par -par - If you add terms to a covered work in accord with this section, youpar -must place, in the relevant source files, a statement of thepar -additional terms that apply to those files, or a notice indicatingpar -where to find the applicable terms.par -par - Additional terms, permissive or non-permissive, may be stated in thepar -form of a separately written license, or stated as exceptions;par -the above requirements apply either way.par -par - 8. Termination.par -par - You may not propagate or modify a covered work except as expresslypar -provided under this License. Any attempt otherwise to propagate orpar -modify it is void, and will automatically terminate your rights underpar -this License (including any patent licenses granted under the thirdpar -paragraph of section 11).par -par - However, if you cease all violation of this License, then yourpar -license from a particular copyright holder is reinstated (a)par -provisionally, unless and until the copyright holder explicitly andpar -finally terminates your license, and (b) permanently, if the copyrightpar -holder fails to notify you of the violation by some reasonable meanspar -prior to 60 days after the cessation.par -par - Moreover, your license from a particular copyright holder ispar -reinstated permanently if the copyright holder notifies you of thepar -violation by some reasonable means, this is the first time you havepar -received notice of violation of this License (for any work) from thatpar -copyright holder, and you cure the violation prior to 30 days afterpar -your receipt of the notice.par -par - Termination of your rights under this section does not terminate thepar -licenses of parties who have received copies or rights from you underpar -this License. If your rights have been terminated and not permanentlypar -reinstated, you do not qualify to receive new licenses for the samepar -material under section 10.par -par - 9. Acceptance Not Required for Having Copies.par -par - You are not required to accept this License in order to receive orpar -run a copy of the Program. Ancillary propagation of a covered workpar -occurring solely as a consequence of using peer-to-peer transmissionpar -to receive a copy likewise does not require acceptance. However,par -nothing other than this License grants you permission to propagate orpar -modify any covered work. These actions infringe copyright if you dopar -not accept this License. Therefore, by modifying or propagating apar -covered work, you indicate your acceptance of this License to do so.par -par - 10. Automatic Licensing of Downstream Recipients.par -par - Each time you convey a covered work, the recipient automaticallypar -receives a license from the original licensors, to run, modify andpar -propagate that work, subject to this License. You are not responsiblepar -for enforcing compliance by third parties with this License.par -par - An "entity transaction" is a transaction transferring control of anpar -organization, or substantially all assets of one, or subdividing anpar -organization, or merging organizations. If propagation of a coveredpar -work results from an entity transaction, each party to thatpar -transaction who receives a copy of the work also receives whateverpar -licenses to the work the party's predecessor in interest had or couldpar -give under the previous paragraph, plus a right to possession of thepar -Corresponding Source of the work from the predecessor in interest, ifpar -the predecessor has it or can get it with reasonable efforts.par -par - You may not impose any further restrictions on the exercise of thepar -rights granted or affirmed under this License. For example, you maypar -not impose a license fee, royalty, or other charge for exercise ofpar -rights granted under this License, and you may not initiate litigationpar -(including a cross-claim or counterclaim in a lawsuit) alleging thatpar -any patent claim is infringed by making, using, selling, offering forpar -sale, or importing the Program or any portion of it.par -par - 11. Patents.par -par - A "contributor" is a copyright holder who authorizes use under thispar -License of the Program or a work on which the Program is based. Thepar -work thus licensed is called the contributor's "contributor version".par -par - A contributor's "essential patent claims" are all patent claimspar -owned or controlled by the contributor, whether already acquired orpar -hereafter acquired, that would be infringed by some manner, permittedpar -by this License, of making, using, or selling its contributor version,par -but do not include claims that would be infringed only as apar -consequence of further modification of the contributor version. Forpar -purposes of this definition, "control" includes the right to grantpar -patent sublicenses in a manner consistent with the requirements ofpar -this License.par -par - Each contributor grants you a non-exclusive, worldwide, royalty-freepar -patent license under the contributor's essential patent claims, topar -make, use, sell, offer for sale, import and otherwise run, modify andpar -propagate the contents of its contributor version.par -par - In the following three paragraphs, a "patent license" is any expresspar -agreement or commitment, however denominated, not to enforce a patentpar -(such as an express permission to practice a patent or covenant not topar -sue for patent infringement). To "grant" such a patent license to apar -party means to make such an agreement or commitment not to enforce apar -patent against the party.par -par - If you convey a covered work, knowingly relying on a patent license,par -and the Corresponding Source of the work is not available for anyonepar -to copy, free of charge and under the terms of this License, through apar -publicly available network server or other readily accessible means,par -then you must either (1) cause the Corresponding Source to be sopar -available, or (2) arrange to deprive yourself of the benefit of thepar -patent license for this particular work, or (3) arrange, in a mannerpar -consistent with the requirements of this License, to extend the patentpar -license to downstream recipients. "Knowingly relying" means you havepar -actual knowledge that, but for the patent license, your conveying thepar -covered work in a country, or your recipient's use of the covered workpar -in a country, would infringe one or more identifiable patents in thatpar -country that you have reason to believe are valid.par -par - If, pursuant to or in connection with a single transaction orpar -arrangement, you convey, or propagate by procuring conveyance of, apar -covered work, and grant a patent license to some of the partiespar -receiving the covered work authorizing them to use, propagate, modifypar -or convey a specific copy of the covered work, then the patent licensepar -you grant is automatically extended to all recipients of the coveredpar -work and works based on it.par -par - A patent license is "discriminatory" if it does not include withinpar -the scope of its coverage, prohibits the exercise of, or ispar -conditioned on the non-exercise of one or more of the rights that arepar -specifically granted under this License. You may not convey a coveredpar -work if you are a party to an arrangement with a third party that ispar -in the business of distributing software, under which you make paymentpar -to the third party based on the extent of your activity of conveyingpar -the work, and under which the third party grants, to any of thepar -parties who would receive the covered work from you, a discriminatorypar -patent license (a) in connection with copies of the covered workpar -conveyed by you (or copies made from those copies), or (b) primarilypar -for and in connection with specific products or compilations thatpar -contain the covered work, unless you entered into that arrangement,par -or that patent license was granted, prior to 28 March 2007.par -par - Nothing in this License shall be construed as excluding or limitingpar -any implied license or other defenses to infringement that maypar -otherwise be available to you under applicable patent law.par -par - 12. No Surrender of Others' Freedom.par -par - If conditions are imposed on you (whether by court order, agreement orpar -otherwise) that contradict the conditions of this License, they do notpar -excuse you from the conditions of this License. If you cannot convey apar -covered work so as to satisfy simultaneously your obligations under thispar -License and any other pertinent obligations, then as a consequence you maypar -not convey it at all. For example, if you agree to terms that obligate youpar -to collect a royalty for further conveying from those to whom you conveypar -the Program, the only way you could satisfy both those terms and thispar -License would be to refrain entirely from conveying the Program.par -par - 13. Use with the GNU Affero General Public License.par -par - Notwithstanding any other provision of this License, you havepar -permission to link or combine any covered work with a work licensedpar -under version 3 of the GNU Affero General Public License into a singlepar -combined work, and to convey the resulting work. The terms of thispar -License will continue to apply to the part which is the covered work,par -but the special requirements of the GNU Affero General Public License,par -section 13, concerning interaction through a network will apply to thepar -combination as such.par -par - 14. Revised Versions of this License.par -par - The Free Software Foundation may publish revised and/or new versions ofpar -the GNU General Public License from time to time. Such new versions willpar -be similar in spirit to the present version, but may differ in detail topar -address new problems or concerns.par -par - Each version is given a distinguishing version number. If thepar -Program specifies that a certain numbered version of the GNU Generalpar -Public License "or any later version" applies to it, you have thepar -option of following the terms and conditions either of that numberedpar -version or of any later version published by the Free Softwarepar -Foundation. If the Program does not specify a version number of thepar -GNU General Public License, you may choose any version ever publishedpar -by the Free Software Foundation.par -par - If the Program specifies that a proxy can decide which futurepar -versions of the GNU General Public License can be used, that proxy'spar -public statement of acceptance of a version permanently authorizes youpar -to choose that version for the Program.par -par - Later license versions may give you additional or differentpar -permissions. However, no additional obligations are imposed on anypar -author or copyright holder as a result of your choosing to follow apar -later version.par -par - 15. Disclaimer of Warranty.par -par - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BYpar -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHTpar -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTYpar -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,par -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULARpar -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAMpar -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OFpar -ALL NECESSARY SERVICING, REPAIR OR CORRECTION.par -par - 16. Limitation of Liability.par -par - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITINGpar -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYSpar -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANYpar -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THEpar -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OFpar -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRDpar -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),par -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OFpar -SUCH DAMAGES.par -par - 17. Interpretation of Sections 15 and 16.par -par - If the disclaimer of warranty and limitation of liability providedpar -above cannot be given local legal effect according to their terms,par -reviewing courts shall apply local law that most closely approximatespar -an absolute waiver of all civil liability in connection with thepar -Program, unless a warranty or assumption of liability accompanies apar -copy of the Program in return for a fee.par -par - END OF TERMS AND CONDITIONSpar -par - How to Apply These Terms to Your New Programspar -par - If you develop a new program, and you want it to be of the greatestpar -possible use to the public, the best way to achieve this is to make itpar -free software which everyone can redistribute and change under these terms.par -par - To do so, attach the following notices to the program. It is safestpar -to attach them to the start of each source file to most effectivelypar -state the exclusion of warranty; and each file should have at leastpar -the "copyright" line and a pointer to where the full notice is found.par -par - par - Copyright (C) par -par - This program is free software: you can redistribute it and/or modifypar - it under the terms of the GNU General Public License as published bypar - the Free Software Foundation, either version 3 of the License, orpar - (at your option) any later version.par -par - This program is distributed in the hope that it will be useful,par - but WITHOUT ANY WARRANTY; without even the implied warranty ofpar - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See thepar - GNU General Public License for more details.par -par - You should have received a copy of the GNU General Public Licensepar - along with this program. If not, see .par -par -Also add information on how to contact you by electronic and paper mail.par -par - If the program does terminal interaction, make it output a shortpar -notice like this when it starts in an interactive mode:par -par - Copyright (C) par - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.par - This is free software, and you are welcome to redistribute itpar - under certain conditions; type `show c' for details.par -par -The hypothetical commands `show w' and `show c' should show the appropriatepar -parts of the General Public License. Of course, your program's commandspar -might be different; for a GUI interface, you would use an "about box".par -par - You should also get your employer (if you work as a programmer) or school,par -if any, to sign a "copyright disclaimer" for the program, if necessary.par -For more information on this, and how to apply and follow the GNU GPL, seepar -.par -par - The GNU General Public License does not permit incorporating your programpar -into proprietary programs. If your program is a subroutine library, youpar -may consider it more useful to permit linking proprietary applications withpar -the library. If this is what you want to do, use the GNU Lesser Generalpar -Public License instead of this License. But first, please readpar -.par - -} \ No newline at end of file +{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}} +\f0\fs20 +OrchestrAD\line +\line +Copyright (c) Grace Solutions. All rights reserved.\line +\line +This software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability arising from, out of or in connection with the software or the use or other dealings in the software.\line +\line +By installing this software you agree to use it in accordance with your organization's policies and applicable law.\line +} -- 2.52.0 From 13b336a77e8fc76917260fe376f99211f476970f Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 22:01:34 -0400 Subject: [PATCH 08/11] 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 --- backend/internal/cli/cli.go | 2 +- backend/internal/directory/ldap/client.go | 10 +++++--- .../connections/ConnectionFormDialog.tsx | 24 ++++++++++++++++++- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index c9f7b83..84df4e6 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -147,7 +147,7 @@ func runServer(ctx context.Context) error { RunRepo: repository.NewRuleRunRepository(database.Conn()), UserRepo: repository.NewUserRepository(database.Conn()), APIKeyService: services.NewAPIKeyService(database.Conn(), logger), - BackupService: services.NewBackupService(database, filepath.Join(cfg.DataPath, "backups"), 10, logger), + BackupService: services.NewBackupService(database, filepath.Join(cfg.DataPath, "backups"), 3, logger), SettingsService: settingsService, DashboardService: services.NewDashboardService(database.Conn(), logger), ActivityService: services.NewActivityService(database.Conn(), logger), diff --git a/backend/internal/directory/ldap/client.go b/backend/internal/directory/ldap/client.go index d1617e5..2638162 100644 --- a/backend/internal/directory/ldap/client.go +++ b/backend/internal/directory/ldap/client.go @@ -51,10 +51,13 @@ func (c *Client) Connect() error { var err error if c.config.UseTLS { - tlsConfig := &tls.Config{ + // ServerName lets certificate verification succeed when the DC cert + // is actually trusted; it is ignored when AllowInvalidCerts skips + // verification (the common non-domain-joined case). + conn, err = ldap.DialTLS("tcp", addr, &tls.Config{ InsecureSkipVerify: c.config.AllowInvalidCerts, - } - conn, err = ldap.DialTLS("tcp", addr, tlsConfig) + ServerName: host, + }) } else { conn, err = ldap.Dial("tcp", addr) } @@ -69,6 +72,7 @@ func (c *Client) Connect() error { if c.config.UseStartTLS { tlsConfig := &tls.Config{ InsecureSkipVerify: c.config.AllowInvalidCerts, + ServerName: host, } if err := conn.StartTLS(tlsConfig); err != nil { conn.Close() diff --git a/frontend/src/app/(app)/connections/ConnectionFormDialog.tsx b/frontend/src/app/(app)/connections/ConnectionFormDialog.tsx index 82d196e..5adcdaa 100644 --- a/frontend/src/app/(app)/connections/ConnectionFormDialog.tsx +++ b/frontend/src/app/(app)/connections/ConnectionFormDialog.tsx @@ -111,6 +111,20 @@ export default function ConnectionFormDialog({ open, connection, onClose, onSave /> ); + // Toggling LDAPS auto-flips the port to the standard (636 on / 389 off) when + // it is still the other default, and — because clients that are not domain + // members usually cannot validate the DC's LDAPS certificate — defaults to + // skipping certificate validation so LDAPS works without importing any certs. + const onLdapsToggle = (v: boolean) => { + setForm((f) => { + const next: Partial = { ...f, useTls: v }; + if (v && (f.port === 389 || f.port == null)) next.port = 636; + if (!v && f.port === 636) next.port = 389; + if (v && !f.allowInvalidCerts) next.allowInvalidCerts = true; + return next; + }); + }; + return ( {connection ? "Edit Connection" : "New Connection"} @@ -131,10 +145,18 @@ export default function ConnectionFormDialog({ open, connection, onClose, onSave {numField("timeoutSeconds", "Timeout (s)", "30")}
- {swField("useTls", "LDAPS (TLS)")} + onLdapsToggle(v)} />} + label="LDAPS (TLS)" + /> {swField("useStartTls", "StartTLS")} {swField("allowInvalidCerts", "Allow invalid certs")} + + LDAPS uses port 636 (auto-set). If this host is not a domain member it usually can't + validate the DC's certificate, so “Allow invalid certs” is enabled automatically — the + connection is still encrypted, the certificate just isn't verified. + Directory -- 2.52.0 From 6587905eeb7574448ffcf4ab72cae56bc9125fac Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 22:01:34 -0400 Subject: [PATCH 09/11] fix(ldap): decode objectGUID/objectSid and binary attributes for display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/internal/directory/ldap/attrformat.go | 109 ++++++++++++++++++ .../internal/services/connection_service.go | 2 +- 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 backend/internal/directory/ldap/attrformat.go diff --git a/backend/internal/directory/ldap/attrformat.go b/backend/internal/directory/ldap/attrformat.go new file mode 100644 index 0000000..7e7c689 --- /dev/null +++ b/backend/internal/directory/ldap/attrformat.go @@ -0,0 +1,109 @@ +// Package ldap - human-readable formatting for binary directory attributes. +package ldap + +import ( + "encoding/base64" + "fmt" + "strings" + "unicode/utf8" + + goldap "github.com/go-ldap/ldap/v3" +) + +// binaryAttrsGUID / binaryAttrsSID name the well-known AD attributes stored as +// raw binary that must be decoded to be legible. +var ( + guidAttrs = map[string]bool{"objectguid": true} + sidAttrs = map[string]bool{"objectsid": true, "sidhistory": true} +) + +// FormatAttributeValues returns display-friendly string values for an attribute: +// objectGUID as a GUID, objectSid/sIDHistory as S-1-… strings, and any other +// value that isn't valid printable UTF-8 as base64. Printable values pass +// through unchanged. +func FormatAttributeValues(attr *goldap.EntryAttribute) []string { + name := strings.ToLower(attr.Name) + + if guidAttrs[name] { + out := make([]string, 0, len(attr.ByteValues)) + for _, b := range attr.ByteValues { + out = append(out, formatGUID(b)) + } + if len(out) > 0 { + return out + } + } + if sidAttrs[name] { + out := make([]string, 0, len(attr.ByteValues)) + for _, b := range attr.ByteValues { + out = append(out, formatSID(b)) + } + if len(out) > 0 { + return out + } + } + + // Generic: keep printable text, base64 anything binary. + out := make([]string, len(attr.Values)) + for i, v := range attr.Values { + if isPrintable(v) { + out[i] = v + } else if i < len(attr.ByteValues) { + out[i] = "base64:" + base64.StdEncoding.EncodeToString(attr.ByteValues[i]) + } else { + out[i] = "base64:" + base64.StdEncoding.EncodeToString([]byte(v)) + } + } + return out +} + +// formatGUID renders a 16-byte AD objectGUID as its canonical string, honoring +// the mixed-endian layout of the first three groups. +func formatGUID(b []byte) string { + if len(b) != 16 { + return "base64:" + base64.StdEncoding.EncodeToString(b) + } + return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + b[3], b[2], b[1], b[0], b[5], b[4], b[7], b[6], + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]) +} + +// formatSID renders a binary NT security identifier as S-R-IA-SA1-SA2-… +func formatSID(b []byte) string { + if len(b) < 8 { + return "base64:" + base64.StdEncoding.EncodeToString(b) + } + revision := b[0] + subCount := int(b[1]) + // Identifier authority: 48-bit big-endian in bytes 2..7. + var authority uint64 + for i := 2; i < 8; i++ { + authority = authority<<8 | uint64(b[i]) + } + sid := fmt.Sprintf("S-%d-%d", revision, authority) + // Sub-authorities: 32-bit little-endian, subCount of them. + for i := 0; i < subCount; i++ { + off := 8 + i*4 + if off+4 > len(b) { + break + } + sub := uint32(b[off]) | uint32(b[off+1])<<8 | uint32(b[off+2])<<16 | uint32(b[off+3])<<24 + sid += fmt.Sprintf("-%d", sub) + } + return sid +} + +func isPrintable(s string) bool { + if !utf8.ValidString(s) { + return false + } + for _, r := range s { + if r == '\t' || r == '\n' || r == '\r' { + continue + } + if r < 0x20 { + return false + } + } + return true +} diff --git a/backend/internal/services/connection_service.go b/backend/internal/services/connection_service.go index 5db72e2..c0deebe 100644 --- a/backend/internal/services/connection_service.go +++ b/backend/internal/services/connection_service.go @@ -266,7 +266,7 @@ func (s *ConnectionService) QueryPreview(conn *models.ADConnection, input QueryP Attributes: map[string][]string{}, } for _, attr := range entry.Attributes { - preview.Attributes[attr.Name] = attr.Values + preview.Attributes[attr.Name] = ldap.FormatAttributeValues(attr) } result.Entries = append(result.Entries, preview) } -- 2.52.0 From fbb308640c6bd9cf8f84d9f4075b628f4d07b2b8 Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 22:08:51 -0400 Subject: [PATCH 10/11] 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 --- .../app/(app)/objects/ObjectDetailDialog.tsx | 17 +++++++++++++++-- frontend/src/app/(app)/objects/page.tsx | 15 ++++++++++++++- .../src/app/(app)/rules/RulePreviewDialog.tsx | 17 ++++++++++++++++- frontend/src/app/(app)/security/page.tsx | 15 ++++++++++++++- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/(app)/objects/ObjectDetailDialog.tsx b/frontend/src/app/(app)/objects/ObjectDetailDialog.tsx index 2664ae3..4c0d533 100644 --- a/frontend/src/app/(app)/objects/ObjectDetailDialog.tsx +++ b/frontend/src/app/(app)/objects/ObjectDetailDialog.tsx @@ -12,6 +12,7 @@ import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import TableHead from "@mui/material/TableHead"; +import TablePagination from "@mui/material/TablePagination"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; @@ -36,12 +37,15 @@ export default function ObjectDetailDialog({ connectionId, dn, onClose }: Props) const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [filter, setFilter] = useState(""); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); useEffect(() => { if (!dn) return; setAttrs(null); setError(null); setFilter(""); + setPage(0); setLoading(true); ConnectionsApi.queryPreview(connectionId, { baseDn: dn, @@ -75,7 +79,7 @@ export default function ObjectDetailDialog({ connectionId, dn, onClose }: Props) ) : ( <> setFilter(e.target.value)} sx={{ mb: 2 }} placeholder="e.g. department" /> + onChange={(e) => { setFilter(e.target.value); setPage(0); }} sx={{ mb: 2 }} placeholder="e.g. department" /> @@ -86,7 +90,7 @@ export default function ObjectDetailDialog({ connectionId, dn, onClose }: Props) - {rows.map(([name, values]) => ( + {rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map(([name, values]) => ( {name} @@ -111,6 +115,15 @@ export default function ObjectDetailDialog({ connectionId, dn, onClose }: Props) )}
+ setPage(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }} + rowsPerPageOptions={[10, 25, 50, 100]} + />
)} diff --git a/frontend/src/app/(app)/objects/page.tsx b/frontend/src/app/(app)/objects/page.tsx index d138790..9f80e96 100644 --- a/frontend/src/app/(app)/objects/page.tsx +++ b/frontend/src/app/(app)/objects/page.tsx @@ -13,6 +13,7 @@ import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; +import TablePagination from "@mui/material/TablePagination"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; @@ -79,6 +80,8 @@ export default function ObjectViewerPage() { const [error, setError] = useState(null); const [searched, setSearched] = useState(false); const [detailDn, setDetailDn] = useState(null); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); useEffect(() => { ConnectionsApi.list({ pageSize: 200 }) @@ -114,6 +117,7 @@ export default function ObjectViewerPage() { }); setEntries(res.entries); setTruncated(res.truncated); + setPage(0); } catch (err) { setError(err instanceof ApiError ? err.message : "Search failed"); setEntries([]); @@ -179,7 +183,7 @@ export default function ObjectViewerPage() { - {entries.map((e) => ( + {entries.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((e) => ( setDetailDn(e.dn)}> {firstAttr(e, "cn", "name", "sAMAccountName") || e.dn} @@ -197,6 +201,15 @@ export default function ObjectViewerPage() { ))} + setPage(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }} + rowsPerPageOptions={[10, 25, 50, 100]} + /> ) : null} diff --git a/frontend/src/app/(app)/rules/RulePreviewDialog.tsx b/frontend/src/app/(app)/rules/RulePreviewDialog.tsx index fa8fb4e..bb44406 100644 --- a/frontend/src/app/(app)/rules/RulePreviewDialog.tsx +++ b/frontend/src/app/(app)/rules/RulePreviewDialog.tsx @@ -15,6 +15,7 @@ import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import TableHead from "@mui/material/TableHead"; +import TablePagination from "@mui/material/TablePagination"; import TableRow from "@mui/material/TableRow"; import Typography from "@mui/material/Typography"; import { useEffect, useState } from "react"; @@ -33,11 +34,14 @@ export default function RulePreviewDialog({ rule, onClose }: Props) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); useEffect(() => { if (!open) return; setError(null); setResult(null); + setPage(0); }, [open, rule]); const run = async () => { @@ -117,7 +121,7 @@ export default function RulePreviewDialog({ rule, onClose }: Props) { - {result.matchedObjects.slice(0, 100).map((m, i) => ( + {result.matchedObjects.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((m, i) => ( {m.canonicalName || "—"} {m.dn} @@ -127,6 +131,17 @@ export default function RulePreviewDialog({ rule, onClose }: Props) { )} + {result.matchedObjects.length > 0 && ( + setPage(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }} + rowsPerPageOptions={[10, 25, 50, 100]} + /> + )} diff --git a/frontend/src/app/(app)/security/page.tsx b/frontend/src/app/(app)/security/page.tsx index e263f87..e49784e 100644 --- a/frontend/src/app/(app)/security/page.tsx +++ b/frontend/src/app/(app)/security/page.tsx @@ -19,6 +19,7 @@ import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; import TableCell from "@mui/material/TableCell"; import TableHead from "@mui/material/TableHead"; +import TablePagination from "@mui/material/TablePagination"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; @@ -240,6 +241,8 @@ function TlsCard() { // Windows store. const [winCerts, setWinCerts] = useState(null); + const [certPage, setCertPage] = useState(0); + const [certRpp, setCertRpp] = useState(10); const [winErr, setWinErr] = useState(null); const [thumb, setThumb] = useState(""); @@ -278,6 +281,7 @@ function TlsCard() { const loadWinCerts = async () => { setWinErr(null); setWinCerts(null); + setCertPage(0); try { setWinCerts(await TlsApi.windowsCerts()); } catch (err) { @@ -391,7 +395,7 @@ function TlsCard() { - {winCerts.map((c) => ( + {winCerts.slice(certPage * certRpp, certPage * certRpp + certRpp).map((c) => ( setThumb(c.thumbprint)} sx={{ cursor: "pointer" }}> @@ -408,6 +412,15 @@ function TlsCard() { ))} + setCertPage(p)} + rowsPerPage={certRpp} + onRowsPerPageChange={(e) => { setCertRpp(parseInt(e.target.value, 10)); setCertPage(0); }} + rowsPerPageOptions={[10, 25, 50]} + /> ) : winCerts ? ( No certificates found. -- 2.52.0 From 35d8b81c91be0ddacbd9054d9618c58fcd07772c Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Wed, 2 Sep 2026 22:14:26 -0400 Subject: [PATCH 11/11] fix(api-keys): scan created_utc as a string in List (modernc timestamp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/internal/services/apikey_service.go | 8 ++- .../internal/services/apikey_service_test.go | 59 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 backend/internal/services/apikey_service_test.go diff --git a/backend/internal/services/apikey_service.go b/backend/internal/services/apikey_service.go index 26353a5..7616f4b 100644 --- a/backend/internal/services/apikey_service.go +++ b/backend/internal/services/apikey_service.go @@ -217,14 +217,20 @@ func (s *APIKeyService) List(userID string) ([]APIKey, error) { for rows.Next() { var k APIKey var isEnabled int + var createdStr string var expires, lastUsed, revoked sql.NullString + // created_utc is scanned as a string: the modernc.org/sqlite driver + // returns TEXT timestamps as strings, not time.Time. if err := rows.Scan( &k.ID, &k.UserID, &k.Name, &k.KeyPrefix, &expires, - &isEnabled, &k.Scope, &lastUsed, &k.CreatedAt, &revoked, + &isEnabled, &k.Scope, &lastUsed, &createdStr, &revoked, ); err != nil { return nil, err } k.IsEnabled = isEnabled != 0 + if t, err := time.Parse(time.RFC3339, createdStr); err == nil { + k.CreatedAt = t + } k.ExpiresAt = parseNullTimeStr(expires) k.LastUsedAt = parseNullTimeStr(lastUsed) k.RevokedAt = parseNullTimeStr(revoked) diff --git a/backend/internal/services/apikey_service_test.go b/backend/internal/services/apikey_service_test.go new file mode 100644 index 0000000..372e23c --- /dev/null +++ b/backend/internal/services/apikey_service_test.go @@ -0,0 +1,59 @@ +package services + +import ( + "path/filepath" + "testing" + + "github.com/Grace-Solutions/OrchestrAD/internal/config" + "github.com/Grace-Solutions/OrchestrAD/internal/db" + "github.com/Grace-Solutions/OrchestrAD/internal/logging" + "github.com/Grace-Solutions/OrchestrAD/internal/models" + "github.com/Grace-Solutions/OrchestrAD/internal/repository" +) + +// TestAPIKeyListRoundTrip guards the modernc string-timestamp scan on the API +// key list (created_utc must not be scanned straight into time.Time). +func TestAPIKeyListRoundTrip(t *testing.T) { + database, err := db.New(config.DatabaseConfig{ + Path: filepath.Join(t.TempDir(), "apikey_test.db"), + MaxOpenConns: 2, MaxIdleConns: 2, WALMode: true, ForeignKeys: true, + }, logging.Default()) + if err != nil { + t.Fatalf("db.New: %v", err) + } + defer database.Close() + if err := database.Migrate(); err != nil { + t.Fatalf("migrate: %v", err) + } + conn := database.Conn() + + // A user to satisfy the api_keys.user_id FK. + userRepo := repository.NewUserRepository(conn) + u := &models.User{Username: "keyowner", IsActive: true} + if err := userRepo.Create(u); err != nil { + t.Fatalf("create user: %v", err) + } + + svc := NewAPIKeyService(conn, logging.Default()) + created, err := svc.Create(CreateAPIKeyInput{UserID: u.ID, Name: "k1", Scope: "read"}) + if err != nil { + t.Fatalf("create key: %v", err) + } + if created.FullKey == "" { + t.Fatal("expected a plaintext key on create") + } + + keys, err := svc.List(u.ID) + if err != nil { + t.Fatalf("list keys: %v", err) + } + if len(keys) != 1 { + t.Fatalf("expected 1 key, got %d", len(keys)) + } + if keys[0].CreatedAt.IsZero() { + t.Errorf("created_utc not parsed") + } + if keys[0].Scope != "read" { + t.Errorf("scope = %q, want read", keys[0].Scope) + } +} -- 2.52.0