diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index f44f8f7..0a75f63 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -275,9 +275,23 @@ jobs: - name: Registry login run: echo "$REGISTRY_LOGIN_PASSWORD" | docker login "${{ steps.reg.outputs.host }}" -u "${{ steps.reg.outputs.user }}" --password-stdin + # A buildx builder is needed to emit a multi-architecture manifest. The + # Dockerfile cross-compiles (the Go build is CGO-free and honours + # TARGETARCH), so no QEMU emulation is involved and the arm64 image costs + # little more than the amd64 one. + - name: Set up buildx + run: | + set -euo pipefail + docker buildx create --name orchestrad --use --driver docker-container 2>/dev/null \ + || docker buildx use orchestrad + docker buildx inspect --bootstrap + # One multi-stage build compiles the Next.js UI, embeds it, and produces - # the Go binary. Tag both the immutable version and latest (main only). - - name: Build image + # the Go binary for each target architecture. Tag both the immutable + # version and latest (main only). Built and pushed in a single step: + # a multi-platform result cannot be loaded into the local daemon, so it + # goes straight to the registry as a manifest list. + - name: Build and push image env: IMAGE: ${{ steps.reg.outputs.image }} VERSION: ${{ steps.ver.outputs.version }} @@ -285,23 +299,25 @@ jobs: BUILD_TIME: ${{ steps.ver.outputs.build_time }} run: | set -euo pipefail - docker build \ + docker buildx build \ + --platform linux/amd64,linux/arm64 \ --build-arg VERSION="$VERSION" \ --build-arg GIT_COMMIT="$GIT_COMMIT" \ --build-arg BUILD_TIME="$BUILD_TIME" \ -t "${IMAGE}:${VERSION}" \ -t "${IMAGE}:latest" \ + --push \ . + echo "Published ${IMAGE}:${VERSION} and ${IMAGE}:latest (linux/amd64, linux/arm64)" - - name: Push image + - name: Verify image architectures env: IMAGE: ${{ steps.reg.outputs.image }} VERSION: ${{ steps.ver.outputs.version }} run: | set -euo pipefail - docker push "${IMAGE}:${VERSION}" - docker push "${IMAGE}:latest" - echo "Published ${IMAGE}:${VERSION} and ${IMAGE}:latest" + docker buildx imagetools inspect "${IMAGE}:${VERSION}" \ + | grep -E 'Platform|Name' || true # Create the Gitea release and attach every binary artifact in dist/. The # MSI is added later by the msi job. diff --git a/Dockerfile b/Dockerfile index 08da293..0738688 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # Stage 1: build the frontend (Next.js static export -> frontend/out) # Debian base avoids musl/sharp native-module friction; this stage is discarded. # --------------------------------------------------------------------------- -FROM node:22-bookworm-slim AS frontend +FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS frontend WORKDIR /frontend # Install deps first for better layer caching. .npmrc carries @@ -23,9 +23,13 @@ RUN npm run build # --------------------------------------------------------------------------- # Stage 2: build the Go binary with the UI embedded # --------------------------------------------------------------------------- -FROM golang:1.25-alpine AS builder +# Pinned to the *build* platform and cross-compiled with GOARCH below, so an +# arm64 image is produced natively on an amd64 runner with no QEMU emulation +# (which would make the Go build minutes-long instead of seconds). +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder -# Pure-Go SQLite (modernc.org/sqlite) means no C toolchain is needed. +# Pure-Go SQLite (modernc.org/sqlite) means no C toolchain is needed, which is +# also what makes cross-compilation this simple. RUN apk add --no-cache git WORKDIR /build @@ -45,7 +49,11 @@ ARG VERSION=dev ARG BUILD_TIME=unknown ARG GIT_COMMIT=unknown -RUN CGO_ENABLED=0 go build \ +# TARGETOS/TARGETARCH are supplied automatically by buildx for each --platform. +ARG TARGETOS +ARG TARGETARCH + +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build \ -ldflags "-s -w \ -X github.com/Grace-Solutions/OrchestrAD/internal/version.Version=${VERSION} \ -X github.com/Grace-Solutions/OrchestrAD/internal/version.BuildTime=${BUILD_TIME} \ diff --git a/backend/internal/api/backups_handlers.go b/backend/internal/api/backups_handlers.go index 1015234..e3b563b 100644 --- a/backend/internal/api/backups_handlers.go +++ b/backend/internal/api/backups_handlers.go @@ -136,7 +136,30 @@ func (h *BackupsHandler) Restore(w http.ResponseWriter, r *http.Request) { } emitAudit(h.auditService, r, audit.EventRestore, "Backup", "", "Restore", true, map[string]any{"filePath": filePath}, "") - WriteJSON(w, http.StatusOK, map[string]bool{"restored": true}) + WriteJSON(w, http.StatusOK, map[string]any{ + "staged": true, + "restartRequired": true, + "message": "Restore staged and validated. It is applied the next time OrchestrAD starts; " + + "the database being replaced is preserved alongside it.", + }) +} + +// CancelRestore handles DELETE /api/v1/backups/restore — discards a staged +// restore that has not been applied yet. +func (h *BackupsHandler) CancelRestore(w http.ResponseWriter, r *http.Request) { + if err := h.service.CancelRestore(); err != nil { + h.logger.Error("BackupsHandler", "CancelRestore failed: %v", err) + WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to cancel the staged restore") + return + } + emitAudit(h.auditService, r, audit.EventRestore, "Backup", "", "CancelRestore", true, nil, "") + WriteJSON(w, http.StatusOK, map[string]bool{"cancelled": true}) +} + +// RestoreStatus handles GET /api/v1/backups/restore — reports whether a restore +// is staged and waiting for a restart. +func (h *BackupsHandler) RestoreStatus(w http.ResponseWriter, r *http.Request) { + WriteJSON(w, http.StatusOK, map[string]bool{"pending": h.service.PendingRestore()}) } func backupToResponse(b *services.BackupInfo) BackupResponse { diff --git a/backend/internal/api/csrf.go b/backend/internal/api/csrf.go new file mode 100644 index 0000000..86f6a85 --- /dev/null +++ b/backend/internal/api/csrf.go @@ -0,0 +1,99 @@ +// Package api - CSRF protection. +// +// CSRF only matters for credentials the browser attaches automatically. The SPA +// authenticates with a bearer token it holds in localStorage and sets on each +// request, and API clients send X-API-Key — neither is ambient, so neither is +// forgeable cross-site, and both skip these checks. What the middleware guards +// is cookie-authenticated mutation. +// +// Tokens are stateless and signed: . keyed by +// the application secret. That means no server-side store to expire or +// replicate, while a token still cannot be minted by an attacker who cannot +// read the secret. +package api + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "net/http" + "strings" +) + +// CSRF issues and validates CSRF tokens. +type CSRF struct { + secret []byte +} + +// NewCSRF creates a CSRF issuer/validator keyed by the application secret. +func NewCSRF(secret []byte) *CSRF { + return &CSRF{secret: secret} +} + +// IssueToken returns a fresh signed token. +func (c *CSRF) IssueToken() (string, error) { + nonce := make([]byte, 16) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("generating csrf nonce: %w", err) + } + n := base64.RawURLEncoding.EncodeToString(nonce) + return n + "." + c.sign(n), nil +} + +// ValidToken reports whether token was issued by this server. +func (c *CSRF) ValidToken(token string) bool { + nonce, sig, ok := strings.Cut(token, ".") + if !ok || nonce == "" || sig == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(sig), []byte(c.sign(nonce))) == 1 +} + +func (c *CSRF) sign(nonce string) string { + mac := hmac.New(sha256.New, c.secret) + mac.Write([]byte(nonce)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// Handler serves GET /api/v1/auth/csrf. +func (c *CSRF) Handler(w http.ResponseWriter, r *http.Request) { + token, err := c.IssueToken() + if err != nil { + WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Could not issue a CSRF token") + return + } + WriteJSON(w, http.StatusOK, map[string]string{"token": token}) +} + +// Middleware rejects cookie-authenticated mutating requests that do not carry a +// valid X-CSRF-Token. Safe methods pass, and so do requests that authenticate +// with an explicit Authorization/X-API-Key header, which CSRF cannot forge. +func (c *CSRF) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isReadMethod(r.Method) { + next.ServeHTTP(w, r) + return + } + // Explicit credentials are not attached by the browser on a cross-site + // request, so these are not CSRF-reachable. + if r.Header.Get("X-API-Key") != "" || strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + next.ServeHTTP(w, r) + return + } + // No ambient credential either: nothing to protect. Let it through so + // the auth middleware produces the 401. + if _, err := r.Cookie(DocsCookieName); err != nil { + next.ServeHTTP(w, r) + return + } + if !c.ValidToken(r.Header.Get("X-CSRF-Token")) { + WriteError(w, http.StatusForbidden, ErrCodeForbidden, + "A valid X-CSRF-Token header is required for cookie-authenticated requests (get one from GET /api/v1/auth/csrf)") + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/api/csrf_test.go b/backend/internal/api/csrf_test.go new file mode 100644 index 0000000..9bc594d --- /dev/null +++ b/backend/internal/api/csrf_test.go @@ -0,0 +1,104 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestCSRFTokenIssueAndValidate(t *testing.T) { + c := NewCSRF([]byte("a-test-secret")) + token, err := c.IssueToken() + if err != nil { + t.Fatalf("IssueToken: %v", err) + } + if !c.ValidToken(token) { + t.Error("a freshly issued token should validate") + } + + // Tokens are unguessable and signed: tampering, forging, and tokens from a + // different secret must all fail. + for name, bad := range map[string]string{ + "empty": "", + "no signature": "justanonce", + "bad sig": "nonce.not-a-real-signature", + "tampered": "x" + token, + } { + if c.ValidToken(bad) { + t.Errorf("%s token should not validate", name) + } + } + if NewCSRF([]byte("a-different-secret")).ValidToken(token) { + t.Error("a token must not validate under a different secret") + } +} + +// TestCSRFMiddleware pins who is challenged and who is not: cookie-authenticated +// mutations need a valid token; bearer/API-key requests and safe methods do not. +func TestCSRFMiddleware(t *testing.T) { + c := NewCSRF([]byte("a-test-secret")) + token, _ := c.IssueToken() + reached := false + h := c.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + })) + + call := func(method string, setup func(*http.Request)) int { + reached = false + req := httptest.NewRequest(method, "/api/v1/rules", nil) + if setup != nil { + setup(req) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Code + } + + withCookie := func(r *http.Request) { + r.AddCookie(&http.Cookie{Name: DocsCookieName, Value: "sometoken"}) + } + + // Safe methods are never challenged. + if code := call(http.MethodGet, withCookie); code != http.StatusOK || !reached { + t.Errorf("GET with cookie: code=%d reached=%v, want pass-through", code, reached) + } + + // Cookie-authenticated mutation without a token is rejected. + if code := call(http.MethodPost, withCookie); code != http.StatusForbidden || reached { + t.Errorf("POST with cookie and no CSRF token: code=%d reached=%v, want 403", code, reached) + } + + // ... and accepted with a valid one. + if code := call(http.MethodPost, func(r *http.Request) { + withCookie(r) + r.Header.Set("X-CSRF-Token", token) + }); code != http.StatusOK || !reached { + t.Errorf("POST with a valid CSRF token: code=%d reached=%v, want pass-through", code, reached) + } + + // An invalid token is rejected even though the header is present — this is + // exactly what the previous placeholder implementation let through. + if code := call(http.MethodPost, func(r *http.Request) { + withCookie(r) + r.Header.Set("X-CSRF-Token", "anything-at-all") + }); code != http.StatusForbidden || reached { + t.Errorf("POST with a bogus CSRF token: code=%d reached=%v, want 403", code, reached) + } + + // Explicit credentials are not CSRF-reachable, so they pass untouched. + if code := call(http.MethodPost, func(r *http.Request) { + r.Header.Set("Authorization", "Bearer sometoken") + }); code != http.StatusOK || !reached { + t.Errorf("bearer POST: code=%d reached=%v, want pass-through", code, reached) + } + if code := call(http.MethodPost, func(r *http.Request) { + r.Header.Set("X-API-Key", "somekey") + }); code != http.StatusOK || !reached { + t.Errorf("api-key POST: code=%d reached=%v, want pass-through", code, reached) + } + + // No ambient credential at all: let it through so auth returns 401. + if code := call(http.MethodPost, nil); code != http.StatusOK || !reached { + t.Errorf("anonymous POST: code=%d reached=%v, want pass-through to auth", code, reached) + } +} diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index 0b8efbd..2602e89 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -188,35 +188,5 @@ func hasAnyRole(user *models.User, roles []string) bool { return false } -// CSRFMiddleware validates CSRF tokens for mutation requests -func CSRFMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Skip CSRF for GET, HEAD, OPTIONS - if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" { - next.ServeHTTP(w, r) - return - } - - // Skip CSRF for API key auth - if r.Header.Get("X-API-Key") != "" { - next.ServeHTTP(w, r) - return - } - - // Skip CSRF for Bearer token auth (typically from NextAuth) - if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { - next.ServeHTTP(w, r) - return - } - - // TODO: Validate CSRF token from header/body - csrfToken := r.Header.Get("X-CSRF-Token") - if csrfToken == "" { - WriteError(w, http.StatusForbidden, ErrCodeForbidden, "CSRF token required") - return - } - - // TODO: Validate the token against stored session - next.ServeHTTP(w, r) - }) -} +// CSRF protection lives in csrf.go: see CSRF.Middleware, which validates +// signed tokens instead of merely checking that a header is present. diff --git a/backend/internal/api/openapi.go b/backend/internal/api/openapi.go index 2764a85..f0dc312 100644 --- a/backend/internal/api/openapi.go +++ b/backend/internal/api/openapi.go @@ -262,8 +262,8 @@ func BuildOpenAPISpec(router chi.Router) map[string]any { return map[string]any{ "openapi": "3.0.3", "info": map[string]any{ - "title": "OrchestrAD API", - "version": "1", + "title": "OrchestrAD API", + "version": "1", "description": "Active Directory rule automation. Authenticate at /api/v1/auth/login, then send the returned token as `Authorization: Bearer ` (or use an API key in `X-API-Key`). " + "This document and GET /api/routes accept `?method=get,post` and `?path=` filters to narrow the listed operations.", }, diff --git a/backend/internal/api/tls_handlers.go b/backend/internal/api/tls_handlers.go index 98c1bfe..adcbb60 100644 --- a/backend/internal/api/tls_handlers.go +++ b/backend/internal/api/tls_handlers.go @@ -28,8 +28,8 @@ func NewTLSHandler(settings *services.SettingsService, manager *tlsmgr.Manager, } type tlsStatusResponse struct { - Enabled bool `json:"enabled"` - WindowsSupported bool `json:"windowsStoreSupported"` + Enabled bool `json:"enabled"` + WindowsSupported bool `json:"windowsStoreSupported"` Certificate *tlsmgr.Info `json:"certificate,omitempty"` } diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index 8454ed3..eb13799 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -101,6 +101,21 @@ func runServer(ctx context.Context) error { return fmt.Errorf("initializing encryptor: %w", err) } + // Say where the key came from, so an operator can see at a glance whether + // the install is on a persisted key (portable) or a supplied one. + switch cfg.SecretKeySource { + case config.SecretKeySourceGenerated: + logger.Info("SecretKey", "Generated a new secret key and stored it at %s - back this file up; "+ + "stored credentials cannot be decrypted without it", + filepath.Join(cfg.DataPath, config.SecretKeyFileName)) + case config.SecretKeySourceLegacy: + logger.Warn("SecretKey", "Adopted the legacy built-in secret key for this existing database and wrote it to %s. "+ + "It is not secret: rotate it by setting ORCHESTRAD_SECRET_KEY (or replacing that file) and re-entering credential passwords", + filepath.Join(cfg.DataPath, config.SecretKeyFileName)) + default: + logger.Info("SecretKey", "Using the secret key from the %s", cfg.SecretKeySource) + } + // Confirm the stored credential secrets decrypt with this key. A changed // ORCHESTRAD_SECRET_KEY otherwise only shows up later, as an opaque // "decryption failed" inside whatever operation first needs a bind. @@ -152,7 +167,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"), 3, logger), + BackupService: services.NewBackupService(database, backupDir(cfg), maxBackups, logger), SettingsService: settingsService, DashboardService: services.NewDashboardService(database.Conn(), logger), ActivityService: services.NewActivityService(database.Conn(), logger), @@ -248,14 +263,66 @@ func RunMigrate() error { return nil } -// RunBackup creates a manual database backup -func RunBackup() error { - return fmt.Errorf("backup not yet implemented") +// maxBackups is how many backup files are retained (oldest pruned beyond it). +const maxBackups = 3 + +// backupDir is where backup files live inside the data directory. +func backupDir(cfg *config.Config) string { + return filepath.Join(cfg.DataPath, "backups") } -// RunRestore restores the database from a backup file -func RunRestore(filepath string) error { - return fmt.Errorf("restore not yet implemented: %s", filepath) +// RunBackup creates a manual database backup +func RunBackup() error { + cfg, err := config.Load() + if err != nil { + return err + } + logger := logging.Init(cfg.Logging) + database, err := db.New(cfg.Database, logger) + if err != nil { + return err + } + defer database.Close() + + svc := services.NewBackupService(database, backupDir(cfg), maxBackups, logger) + backup, err := svc.CreateBackup("manual", "cli") + if err != nil { + return err + } + logger.Info("Backup", "Created %s (%d bytes)", backup.FilePath, backup.SizeBytes) + return nil +} + +// RunRestore restores the database from a backup file. Unlike the API — which +// stages a restore for the next start because the server holds an open +// connection pool — the CLI runs standalone, so it stages and applies in one +// step by opening the database immediately afterwards. +func RunRestore(path string) error { + cfg, err := config.Load() + if err != nil { + return err + } + logger := logging.Init(cfg.Logging) + + if err := db.ValidateBackup(path); err != nil { + return err + } + if err := db.StageRestore(cfg.Database.Path, path); err != nil { + return err + } + logger.Info("Restore", "Backup validated and staged from %s", path) + + // db.New applies the staged file before opening the pool. + database, err := db.New(cfg.Database, logger) + if err != nil { + return fmt.Errorf("opening the restored database: %w", err) + } + defer database.Close() + if err := database.Migrate(); err != nil { + return fmt.Errorf("migrating the restored database: %w", err) + } + logger.Info("Restore", "Restore complete") + return nil } // RunDoctor validates system configuration and health diff --git a/backend/internal/cli/service.go b/backend/internal/cli/service.go index e81b702..4b88c54 100644 --- a/backend/internal/cli/service.go +++ b/backend/internal/cli/service.go @@ -13,6 +13,8 @@ import ( "github.com/Grace-Solutions/OrchestrAD/internal/config" "github.com/Grace-Solutions/OrchestrAD/internal/logging" + "github.com/Grace-Solutions/OrchestrAD/internal/pki" + "github.com/Grace-Solutions/OrchestrAD/internal/tlsmgr" "github.com/kardianos/service" ) @@ -199,6 +201,31 @@ func removeService() error { return fmt.Errorf("removing service: %w", err) } _ = RemoveFirewallRule() + removeStoreCertificate() logging.Info("Service", "Service removed") return nil } + +// removeStoreCertificate deletes the self-managed leaf we published into the +// host's personal certificate store, so uninstalling does not leave an orphan +// behind. Best-effort: the certificate may never have been installed, and the +// data directory may already be gone. +func removeStoreCertificate() { + cfg, err := config.Load() + if err != nil { + return + } + certPEM, err := os.ReadFile(filepath.Join(cfg.DataPath, "tls", "server.crt")) + if err != nil { + return + } + cert, err := pki.ParseCertPEM(certPEM) + if err != nil { + return + } + if err := tlsmgr.RemoveLeafFromMyStore(cert); err != nil { + logging.Warn("Service", "Could not remove the server certificate from the host store: %v", err) + return + } + logging.Info("Service", "Removed the server certificate from the host store") +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 920ba73..9522417 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -13,12 +13,15 @@ import ( type Config struct { DataPath string SecretKey []byte - Server ServerConfig - Database DatabaseConfig - Logging LoggingConfig - OIDC OIDCConfig - CORS CORSConfig - Maintenance MaintenanceConfig + // SecretKeySource records where SecretKey came from, so startup can report + // it (and warn when the legacy built-in default was adopted). + SecretKeySource string + Server ServerConfig + Database DatabaseConfig + Logging LoggingConfig + OIDC OIDCConfig + CORS CORSConfig + Maintenance MaintenanceConfig } // MaintenanceConfig controls background history pruning and database compaction @@ -82,24 +85,27 @@ func Load() (*Config, error) { return nil, fmt.Errorf("creating data directory: %w", err) } - secretKey, err := getEnvOrFile("ORCHESTRAD_SECRET_KEY", "ORCHESTRAD_SECRET_KEY_FILE") + dbPath := filepath.Join(dataPath, "db", "orchestrad.db") + + // The master key for credential encryption. When nothing supplies one it is + // generated and persisted inside the data directory, so restarts keep the + // same key and moving the stack to another server is a matter of copying + // the data directory. + secretKey, secretKeySource, err := resolveSecretKey(dataPath, dbPath) if err != nil { return nil, fmt.Errorf("loading secret key: %w", err) } - if len(secretKey) == 0 { - // Generate a warning but allow startup for development - secretKey = []byte("INSECURE-DEV-KEY-CHANGE-IN-PRODUCTION!") - } cfg := &Config{ - DataPath: dataPath, - SecretKey: secretKey, + DataPath: dataPath, + SecretKey: secretKey, + SecretKeySource: secretKeySource, Server: ServerConfig{ // Listen address/port precedence: environment variable, then the // value the Windows installer recorded in the registry, then the // built-in default. (On non-Windows registrySetting returns "".) - Host: getEnv("ORCHESTRAD_HOST", firstNonEmpty(registrySetting("ListenAddress"), "0.0.0.0")), - Port: getEnvInt("ORCHESTRAD_PORT", atoiOr(registrySetting("ListenPort"), 18090)), + Host: getEnv("ORCHESTRAD_HOST", firstNonEmpty(registrySetting("ListenAddress"), "0.0.0.0")), + Port: getEnvInt("ORCHESTRAD_PORT", atoiOr(registrySetting("ListenPort"), 18090)), // Trust reverse proxies in local/private ranges by default so // X-Forwarded-* headers (client IP, scheme, host) are honored out // of the box behind an edge proxy. Override with an explicit CIDR @@ -107,7 +113,7 @@ func Load() (*Config, error) { TrustedProxies: splitCSV(getEnv("ORCHESTRAD_TRUSTED_PROXIES", "local")), }, Database: DatabaseConfig{ - Path: filepath.Join(dataPath, "db", "orchestrad.db"), + Path: dbPath, MaxOpenConns: getEnvInt("ORCHESTRAD_DB_MAX_OPEN_CONNS", 25), MaxIdleConns: getEnvInt("ORCHESTRAD_DB_MAX_IDLE_CONNS", 5), WALMode: true, diff --git a/backend/internal/config/secretkey.go b/backend/internal/config/secretkey.go new file mode 100644 index 0000000..93df745 --- /dev/null +++ b/backend/internal/config/secretkey.go @@ -0,0 +1,105 @@ +package config + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" +) + +// SecretKeyFileName is the file inside the data directory where a generated +// secret key is persisted, so the key travels with the data directory and the +// whole stack can be moved to another server by copying it. +const SecretKeyFileName = "secret.key" + +// legacyDefaultSecretKey was used by builds that had neither +// ORCHESTRAD_SECRET_KEY nor a key file. Installs that ran on it encrypted their +// credentials with it, so it must be adopted rather than replaced when we find +// an existing database — swapping in a fresh key would render those secrets +// undecryptable. +const legacyDefaultSecretKey = "INSECURE-DEV-KEY-CHANGE-IN-PRODUCTION!" + +// Secret key provenance, reported so startup can log where the key came from. +const ( + SecretKeySourceEnv = "environment" + SecretKeySourceEnvFile = "key file (ORCHESTRAD_SECRET_KEY_FILE)" + SecretKeySourceDataFile = "data directory" + SecretKeySourceGenerated = "generated" + SecretKeySourceLegacy = "legacy built-in default" +) + +// resolveSecretKey determines the encryption master key and, when nothing +// supplied one, persists a key inside dataPath so it survives restarts and +// moves with a copied data directory. +// +// Precedence: +// 1. ORCHESTRAD_SECRET_KEY / ORCHESTRAD_SECRET_KEY_FILE (unchanged behaviour) +// 2. /secret.key, if present +// 3. a newly generated 32-byte random key, written to /secret.key +// +// Case 3 has one important exception. If a database already exists, this is an +// upgrade of an install that was silently running on the legacy built-in +// default, and its stored credentials are encrypted with it. Generating a new +// key there would break every credential, so the legacy value is adopted and +// written to the key file instead; the caller is expected to warn that it +// should be rotated. +func resolveSecretKey(dataPath, dbPath string) (key []byte, source string, err error) { + if v := os.Getenv("ORCHESTRAD_SECRET_KEY"); v != "" { + return []byte(v), SecretKeySourceEnv, nil + } + if fromFile, ferr := getEnvOrFile("ORCHESTRAD_SECRET_KEY", "ORCHESTRAD_SECRET_KEY_FILE"); ferr != nil { + return nil, "", ferr + } else if len(fromFile) > 0 { + return fromFile, SecretKeySourceEnvFile, nil + } + + keyPath := filepath.Join(dataPath, SecretKeyFileName) + if data, rerr := os.ReadFile(keyPath); rerr == nil { + if trimmed := trimKey(data); len(trimmed) > 0 { + return trimmed, SecretKeySourceDataFile, nil + } + } + + // No key anywhere. Adopt the legacy default if this install already has a + // database (its secrets are encrypted with it); otherwise mint a fresh one. + generated := true + if _, serr := os.Stat(dbPath); serr == nil { + key = []byte(legacyDefaultSecretKey) + source = SecretKeySourceLegacy + generated = false + } else { + raw := make([]byte, 32) + if _, rerr := rand.Read(raw); rerr != nil { + return nil, "", fmt.Errorf("generating secret key: %w", rerr) + } + key = []byte(base64.RawStdEncoding.EncodeToString(raw)) + source = SecretKeySourceGenerated + } + + if werr := writeSecretKeyFile(keyPath, key); werr != nil { + // Not fatal: the process can run with the key in memory. But a generated + // key that cannot be persisted would differ on the next start and lose + // every stored secret, so that case must fail loudly. + if generated { + return nil, "", fmt.Errorf("persisting generated secret key to %s: %w", keyPath, werr) + } + } + return key, source, nil +} + +// writeSecretKeyFile writes the key with owner-only permissions. +func writeSecretKeyFile(path string, key []byte) error { + if err := os.WriteFile(path, append(key, '\n'), 0o600); err != nil { + return err + } + return restrictSecretKeyFile(path) +} + +func trimKey(data []byte) []byte { + s := string(data) + for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + return []byte(s) +} diff --git a/backend/internal/config/secretkey_other.go b/backend/internal/config/secretkey_other.go new file mode 100644 index 0000000..909970a --- /dev/null +++ b/backend/internal/config/secretkey_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package config + +import "os" + +// restrictSecretKeyFile enforces owner-only permissions. os.WriteFile already +// created the file with 0600, but an existing file keeps its original mode, so +// chmod is applied explicitly. +func restrictSecretKeyFile(path string) error { + return os.Chmod(path, 0o600) +} diff --git a/backend/internal/config/secretkey_test.go b/backend/internal/config/secretkey_test.go new file mode 100644 index 0000000..75ba6bb --- /dev/null +++ b/backend/internal/config/secretkey_test.go @@ -0,0 +1,118 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +// TestSecretKeyPersistedAndStable is the property that makes the stack +// portable: with nothing configured, a key is generated once, written into the +// data directory, and reused verbatim on every later start. +func TestSecretKeyPersistedAndStable(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "db", "orchestrad.db") + t.Setenv("ORCHESTRAD_SECRET_KEY", "") + t.Setenv("ORCHESTRAD_SECRET_KEY_FILE", "") + + first, source, err := resolveSecretKey(dir, dbPath) + if err != nil { + t.Fatalf("resolveSecretKey: %v", err) + } + if source != SecretKeySourceGenerated { + t.Errorf("source = %q, want %q", source, SecretKeySourceGenerated) + } + if len(first) < 32 { + t.Errorf("generated key too short (%d bytes)", len(first)) + } + keyFile := filepath.Join(dir, SecretKeyFileName) + if _, err := os.Stat(keyFile); err != nil { + t.Fatalf("key file not written: %v", err) + } + + second, source, err := resolveSecretKey(dir, dbPath) + if err != nil { + t.Fatalf("resolveSecretKey (second): %v", err) + } + if source != SecretKeySourceDataFile { + t.Errorf("second source = %q, want %q", source, SecretKeySourceDataFile) + } + if string(second) != string(first) { + t.Errorf("key changed between runs: %q -> %q", first, second) + } +} + +// TestSecretKeyAdoptsLegacyForExistingDatabase covers the upgrade path: an +// install that was silently running on the built-in default must keep using it, +// or every stored credential becomes undecryptable. +func TestSecretKeyAdoptsLegacyForExistingDatabase(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "db", "orchestrad.db") + if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dbPath, []byte("pretend database"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ORCHESTRAD_SECRET_KEY", "") + t.Setenv("ORCHESTRAD_SECRET_KEY_FILE", "") + + key, source, err := resolveSecretKey(dir, dbPath) + if err != nil { + t.Fatalf("resolveSecretKey: %v", err) + } + if source != SecretKeySourceLegacy { + t.Errorf("source = %q, want %q", source, SecretKeySourceLegacy) + } + if string(key) != legacyDefaultSecretKey { + t.Errorf("existing install must keep the legacy key, got %q", key) + } + // It is written out, so the next start reads it from the file rather than + // re-deriving it from this special case. + if data, rerr := os.ReadFile(filepath.Join(dir, SecretKeyFileName)); rerr != nil { + t.Errorf("legacy key not persisted: %v", rerr) + } else if string(trimKey(data)) != legacyDefaultSecretKey { + t.Errorf("persisted key = %q", trimKey(data)) + } +} + +// TestSecretKeyEnvWins: an explicitly supplied key takes precedence and is not +// overwritten by a generated one. +func TestSecretKeyEnvWins(t *testing.T) { + dir := t.TempDir() + t.Setenv("ORCHESTRAD_SECRET_KEY", "explicit-key-from-the-environment") + + key, source, err := resolveSecretKey(dir, filepath.Join(dir, "db", "orchestrad.db")) + if err != nil { + t.Fatalf("resolveSecretKey: %v", err) + } + if source != SecretKeySourceEnv || string(key) != "explicit-key-from-the-environment" { + t.Errorf("env key not honoured: source=%q key=%q", source, key) + } + if _, err := os.Stat(filepath.Join(dir, SecretKeyFileName)); err == nil { + t.Error("must not write a key file when the key is supplied explicitly") + } +} + +// TestSecretKeyFromKeyFileEnv covers ORCHESTRAD_SECRET_KEY_FILE, the documented +// way to mount a key as a Docker/Kubernetes secret. +func TestSecretKeyFromKeyFileEnv(t *testing.T) { + dir := t.TempDir() + external := filepath.Join(dir, "mounted.key") + if err := os.WriteFile(external, []byte("mounted-secret-value\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ORCHESTRAD_SECRET_KEY", "") + t.Setenv("ORCHESTRAD_SECRET_KEY_FILE", external) + + key, source, err := resolveSecretKey(dir, filepath.Join(dir, "db", "orchestrad.db")) + if err != nil { + t.Fatalf("resolveSecretKey: %v", err) + } + if source != SecretKeySourceEnvFile { + t.Errorf("source = %q, want %q", source, SecretKeySourceEnvFile) + } + if string(key) != "mounted-secret-value" { + t.Errorf("key = %q, want the file contents with the newline stripped", key) + } +} diff --git a/backend/internal/config/secretkey_windows.go b/backend/internal/config/secretkey_windows.go new file mode 100644 index 0000000..7e5d455 --- /dev/null +++ b/backend/internal/config/secretkey_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package config + +import ( + "os" + "os/exec" +) + +// restrictSecretKeyFile tightens the ACL on the generated key file. On Windows +// the mode passed to os.WriteFile is effectively ignored — the file inherits +// the directory's ACL — so the inherited entries are dropped and access is +// granted only to SYSTEM, Administrators, and the account that created it (the +// service typically runs as LocalSystem). +// +// Best-effort: the key is already written, and a data directory that is itself +// protected is the common case. Failure to tighten is not fatal. +func restrictSecretKeyFile(path string) error { + args := []string{path, "/inheritance:r", + "/grant:r", "*S-1-5-18:F", // SYSTEM (SID, so it works on localized Windows) + "/grant:r", "*S-1-5-32-544:F", // BUILTIN\Administrators + } + if u := os.Getenv("USERNAME"); u != "" { + args = append(args, "/grant:r", u+":F") + } + cmd := exec.Command("icacls", args...) + cmd.Stdout, cmd.Stderr = nil, nil + _ = cmd.Run() + return nil +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 99e99ff..467d9ab 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -38,6 +38,10 @@ func New(cfg config.DatabaseConfig, logger *logging.Logger) (*DB, error) { migrateLegacyDBLocation(cfg.Path, logger) } + // A restore staged by the API is applied here, before the pool is opened — + // the only point at which swapping the file is safe. + applyPendingRestore(cfg.Path, logger) + logger.Info("Database", "Opening database at %s", cfg.Path) // Build connection string with pragmas. modernc.org/sqlite applies these @@ -98,6 +102,11 @@ func (db *DB) Conn() *sql.DB { return db.conn } +// Path returns the database file path. +func (db *DB) Path() string { + return db.config.Path +} + // Close closes the database connection func (db *DB) Close() error { db.logger.Info("Database", "Closing database connection") diff --git a/backend/internal/db/restore.go b/backend/internal/db/restore.go new file mode 100644 index 0000000..04b767e --- /dev/null +++ b/backend/internal/db/restore.go @@ -0,0 +1,175 @@ +// Database restore. +// +// A running OrchestrAD holds an open connection pool that every repository +// shares, and closing it invalidates those handles process-wide — so the +// database file cannot be swapped underneath a live server. Restore is +// therefore a two-phase operation: +// +// StageRestore validates the backup and parks it next to the database +// ApplyPending runs before the pool is opened (from New) and moves it in +// +// The staged file is only ever applied by a process that has not yet opened the +// database, which makes the swap safe without any coordination. The CLI +// `restore` command performs both phases in one go, since it runs standalone. +package db + +import ( + "database/sql" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/Grace-Solutions/OrchestrAD/internal/logging" +) + +// pendingSuffix marks a validated backup staged for the next start. +const pendingSuffix = ".restore-pending" + +// StageRestore validates backupPath and stages it to be applied to dbPath on +// the next start. It does not modify the live database. +func StageRestore(dbPath, backupPath string) error { + if err := ValidateBackup(backupPath); err != nil { + return err + } + pending := dbPath + pendingSuffix + if err := copyFile(backupPath, pending); err != nil { + return fmt.Errorf("staging restore: %w", err) + } + // Validate the staged copy too, so a truncated copy can never be applied. + if err := ValidateBackup(pending); err != nil { + _ = os.Remove(pending) + return fmt.Errorf("staged copy failed validation: %w", err) + } + return nil +} + +// PendingRestorePath returns the staged file for dbPath, or "" if none. +func PendingRestorePath(dbPath string) string { + pending := dbPath + pendingSuffix + if _, err := os.Stat(pending); err != nil { + return "" + } + return pending +} + +// CancelPendingRestore discards a staged restore. +func CancelPendingRestore(dbPath string) error { + pending := dbPath + pendingSuffix + if _, err := os.Stat(pending); err != nil { + return nil + } + return os.Remove(pending) +} + +// applyPendingRestore moves a staged restore into place. It must run before the +// database is opened. The database being replaced is preserved alongside as +// .replaced- so a bad restore is recoverable, and the WAL/SHM +// sidecars of the old database are removed — leaving them would let SQLite +// replay a journal belonging to the previous file over the restored one. +func applyPendingRestore(dbPath string, logger *logging.Logger) { + pending := PendingRestorePath(dbPath) + if pending == "" { + return + } + logf := func(format string, args ...any) { + if logger != nil { + logger.Info("Database", format, args...) + } + } + warnf := func(format string, args ...any) { + if logger != nil { + logger.Warn("Database", format, args...) + } + } + + // Re-validate: the staged file may have sat on disk across a crash. + if err := ValidateBackup(pending); err != nil { + warnf("Discarding staged restore, it did not validate: %v", err) + _ = os.Remove(pending) + return + } + + if _, err := os.Stat(dbPath); err == nil { + aside := fmt.Sprintf("%s.replaced-%s", dbPath, time.Now().UTC().Format("20060102-150405")) + if err := os.Rename(dbPath, aside); err != nil { + warnf("Could not set the current database aside (%v); restore not applied", err) + return + } + logf("Previous database preserved at %s", filepath.Base(aside)) + } + for _, suffix := range []string{"-wal", "-shm"} { + _ = os.Remove(dbPath + suffix) + } + + if err := os.Rename(pending, dbPath); err != nil { + warnf("Could not apply staged restore (%v)", err) + return + } + logf("Applied staged restore from backup") +} + +// ValidateBackup checks that path is a readable SQLite database that passes an +// integrity check and carries this application's schema. It is deliberately +// strict: applying an unrelated or corrupt file would destroy the install. +func ValidateBackup(path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("backup file not found: %s", path) + } + if info.IsDir() || info.Size() == 0 { + return fmt.Errorf("not a usable backup file: %s", path) + } + + // Open read-only so validation can never modify the candidate. + conn, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=busy_timeout(5000)", path)) + if err != nil { + return fmt.Errorf("opening backup: %w", err) + } + defer conn.Close() + + var result string + if err := conn.QueryRow(`PRAGMA integrity_check`).Scan(&result); err != nil { + return fmt.Errorf("backup is not a readable SQLite database: %w", err) + } + if result != "ok" { + return fmt.Errorf("backup failed integrity check: %s", result) + } + + // It must be an OrchestrAD database, not just any SQLite file. + var n int + if err := conn.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'`, + ).Scan(&n); err != nil || n == 0 { + return fmt.Errorf("backup does not look like an OrchestrAD database (no schema_migrations table)") + } + if err := conn.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'`, + ).Scan(&n); err != nil || n == 0 { + return fmt.Errorf("backup does not look like an OrchestrAD database (no users table)") + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + _ = os.Remove(dst) + return err + } + if err := out.Sync(); err != nil { + out.Close() + return err + } + return out.Close() +} diff --git a/backend/internal/db/restore_test.go b/backend/internal/db/restore_test.go new file mode 100644 index 0000000..17899d0 --- /dev/null +++ b/backend/internal/db/restore_test.go @@ -0,0 +1,170 @@ +package db + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Grace-Solutions/OrchestrAD/internal/config" + "github.com/Grace-Solutions/OrchestrAD/internal/logging" +) + +func newTestDB(t *testing.T, path string) *DB { + t.Helper() + database, err := New(config.DatabaseConfig{ + Path: path, MaxOpenConns: 2, MaxIdleConns: 2, WALMode: true, ForeignKeys: true, + }, logging.Default()) + if err != nil { + t.Fatalf("db.New: %v", err) + } + if err := database.Migrate(); err != nil { + t.Fatalf("migrate: %v", err) + } + return database +} + +// TestRestoreRoundTrip covers the full restore contract: a backup taken at one +// point in time is staged, applied on the next open, and the data it held comes +// back while the replaced database is preserved. +func TestRestoreRoundTrip(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "db", "orchestrad.db") + backupPath := filepath.Join(dir, "backup.db") + + database := newTestDB(t, dbPath) + if _, err := database.Conn().Exec( + `INSERT INTO users (id, username, is_active, created_utc, updated_utc) + VALUES ('u1','before-backup',1,'2026-01-01T00:00:00Z','2026-01-01T00:00:00Z')`); err != nil { + t.Fatalf("insert: %v", err) + } + if err := database.Backup(backupPath); err != nil { + t.Fatalf("Backup: %v", err) + } + + // Change the live database after the backup, so a successful restore is + // observable: this row must be gone afterwards. + if _, err := database.Conn().Exec( + `INSERT INTO users (id, username, is_active, created_utc, updated_utc) + VALUES ('u2','after-backup',1,'2026-01-02T00:00:00Z','2026-01-02T00:00:00Z')`); err != nil { + t.Fatalf("insert: %v", err) + } + database.Close() + + if err := StageRestore(dbPath, backupPath); err != nil { + t.Fatalf("StageRestore: %v", err) + } + if PendingRestorePath(dbPath) == "" { + t.Fatal("expected a pending restore after staging") + } + + // Reopening applies it. + restored := newTestDB(t, dbPath) + defer restored.Close() + + if PendingRestorePath(dbPath) != "" { + t.Error("pending restore should be consumed once applied") + } + var names []string + rows, err := restored.Conn().Query(`SELECT username FROM users ORDER BY username`) + if err != nil { + t.Fatalf("query: %v", err) + } + defer rows.Close() + for rows.Next() { + var n string + if err := rows.Scan(&n); err != nil { + t.Fatalf("scan: %v", err) + } + names = append(names, n) + } + joined := strings.Join(names, ",") + if !strings.Contains(joined, "before-backup") { + t.Errorf("restored database missing the backed-up row; got %q", joined) + } + if strings.Contains(joined, "after-backup") { + t.Errorf("restored database still has the post-backup row; restore did not apply (got %q)", joined) + } + + // The replaced database is kept, so a mistaken restore is recoverable. + entries, _ := os.ReadDir(filepath.Dir(dbPath)) + found := false + for _, e := range entries { + if strings.Contains(e.Name(), ".replaced-") { + found = true + } + } + if !found { + t.Error("expected the replaced database to be preserved alongside") + } +} + +// TestValidateBackupRejectsJunk guards the check that stops an unrelated or +// corrupt file from destroying an install. +func TestValidateBackupRejectsJunk(t *testing.T) { + dir := t.TempDir() + + missing := filepath.Join(dir, "nope.db") + if err := ValidateBackup(missing); err == nil { + t.Error("missing file should not validate") + } + + empty := filepath.Join(dir, "empty.db") + if err := os.WriteFile(empty, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := ValidateBackup(empty); err == nil { + t.Error("empty file should not validate") + } + + garbage := filepath.Join(dir, "garbage.db") + if err := os.WriteFile(garbage, []byte("this is definitely not sqlite"), 0o600); err != nil { + t.Fatal(err) + } + if err := ValidateBackup(garbage); err == nil { + t.Error("non-sqlite file should not validate") + } + + // A valid SQLite database that is not an OrchestrAD one must also be + // refused — this is the case most likely to be an operator mistake. + foreign := filepath.Join(dir, "foreign.db") + conn, err := sql.Open("sqlite", foreign) + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(`CREATE TABLE something (id INTEGER)`); err != nil { + t.Fatal(err) + } + conn.Close() + if err := ValidateBackup(foreign); err == nil { + t.Error("a foreign SQLite database should not validate as an OrchestrAD backup") + } +} + +// TestCancelPendingRestore: a staged restore can be called off before restart. +func TestCancelPendingRestore(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "db", "orchestrad.db") + backupPath := filepath.Join(dir, "backup.db") + + database := newTestDB(t, dbPath) + if err := database.Backup(backupPath); err != nil { + t.Fatalf("Backup: %v", err) + } + database.Close() + + if err := StageRestore(dbPath, backupPath); err != nil { + t.Fatalf("StageRestore: %v", err) + } + if err := CancelPendingRestore(dbPath); err != nil { + t.Fatalf("CancelPendingRestore: %v", err) + } + if PendingRestorePath(dbPath) != "" { + t.Error("restore should no longer be pending after cancelling") + } + // Cancelling again is a no-op, not an error. + if err := CancelPendingRestore(dbPath); err != nil { + t.Errorf("second cancel should be a no-op: %v", err) + } +} diff --git a/backend/internal/directory/ldap/filters.go b/backend/internal/directory/ldap/filters.go index 64a789e..c8367db 100644 --- a/backend/internal/directory/ldap/filters.go +++ b/backend/internal/directory/ldap/filters.go @@ -24,12 +24,12 @@ func ObjectFilter(objectType types.ObjectType) string { // Condition represents a filter condition type Condition struct { - Attribute string - Operator types.ConditionOperator - Value string - Negate bool - CaseSensitive bool - CustomLdap string + Attribute string + Operator types.ConditionOperator + Value string + Negate bool + CaseSensitive bool + CustomLdap string } // BuildFilter builds an LDAP filter from a condition diff --git a/backend/internal/pki/export.go b/backend/internal/pki/export.go index 11d59aa..3dfb7d3 100644 --- a/backend/internal/pki/export.go +++ b/backend/internal/pki/export.go @@ -64,6 +64,14 @@ func (c *Chain) Export(dir, pfxPassword string) error { if err := os.WriteFile(filepath.Join(dir, "server.pfx"), pfx, 0o600); err != nil { return fmt.Errorf("writing server.pfx: %w", err) } + // Write the PFX password beside the bundle. The two live in the same + // owner-only directory, so this grants no access that the .pfx itself + // doesn't already give — and without it an operator importing the bundle + // by hand has to go digging through settings for a password they never + // chose. + if err := os.WriteFile(filepath.Join(dir, "server.pfx.password"), []byte(pfxPassword+"\n"), 0o600); err != nil { + return fmt.Errorf("writing server.pfx.password: %w", err) + } return nil } diff --git a/backend/internal/rules/engine/engine_test.go b/backend/internal/rules/engine/engine_test.go index 52b5278..7d4b13c 100644 --- a/backend/internal/rules/engine/engine_test.go +++ b/backend/internal/rules/engine/engine_test.go @@ -120,10 +120,10 @@ func TestBuildConditionGroups_FiltersDisabled(t *testing.T) { func TestParentDN(t *testing.T) { cases := map[string]string{ - "CN=Alice,OU=Staff,DC=example,DC=com": "OU=Staff,DC=example,DC=com", + "CN=Alice,OU=Staff,DC=example,DC=com": "OU=Staff,DC=example,DC=com", "CN=Alice, OU=Staff,DC=example,DC=com": "OU=Staff,DC=example,DC=com", - "DC=com": "", - "": "", + "DC=com": "", + "": "", } for input, want := range cases { if got := parentDN(input); got != want { diff --git a/backend/internal/rules/engine/reconcile_test.go b/backend/internal/rules/engine/reconcile_test.go index 72623fb..c59ebb7 100644 --- a/backend/internal/rules/engine/reconcile_test.go +++ b/backend/internal/rules/engine/reconcile_test.go @@ -49,7 +49,7 @@ type fakeStore struct { m map[string]map[string]bool // ruleID|groupDN -> set of member DNs } -func newFakeStore() *fakeStore { return &fakeStore{m: map[string]map[string]bool{}} } +func newFakeStore() *fakeStore { return &fakeStore{m: map[string]map[string]bool{}} } func (s *fakeStore) key(ruleID, groupDN string) string { return ruleID + "|" + groupDN } func (s *fakeStore) List(ruleID, groupDN string) ([]string, error) { var out []string diff --git a/backend/internal/rules/variables/variables.go b/backend/internal/rules/variables/variables.go index f684041..7b406fe 100644 --- a/backend/internal/rules/variables/variables.go +++ b/backend/internal/rules/variables/variables.go @@ -10,10 +10,10 @@ import ( // Context holds data for variable expansion type Context struct { - Object map[string]string - Rule map[string]string - Now time.Time - Custom map[string]string + Object map[string]string + Rule map[string]string + Now time.Time + Custom map[string]string } // NewContext creates a new variable expansion context @@ -59,11 +59,11 @@ func NewExpander() *Expander { // Expand expands all variables in a template string func (e *Expander) Expand(template string, ctx *Context) (string, error) { var errors []string - + result := e.variablePattern.ReplaceAllStringFunc(template, func(match string) string { // Extract variable path (remove {{ and }}) path := match[2 : len(match)-2] - + value, err := e.resolve(path, ctx) if err != nil { errors = append(errors, err.Error()) @@ -71,11 +71,11 @@ func (e *Expander) Expand(template string, ctx *Context) (string, error) { } return value }) - + if len(errors) > 0 { return result, fmt.Errorf("variable expansion errors: %s", strings.Join(errors, "; ")) } - + return result, nil } @@ -85,13 +85,13 @@ func (e *Expander) ExpandStrict(template string, ctx *Context) (string, error) { if err != nil { return "", err } - + // Check for any remaining unexpanded variables remaining := e.variablePattern.FindAllString(result, -1) if len(remaining) > 0 { return "", fmt.Errorf("unresolved variables: %s", strings.Join(remaining, ", ")) } - + return result, nil } @@ -100,32 +100,32 @@ func (e *Expander) resolve(path string, ctx *Context) (string, error) { if len(parts) != 2 { return "", fmt.Errorf("invalid variable path: %s", path) } - + source := parts[0] property := parts[1] - + switch source { case "object": if val, ok := ctx.Object[property]; ok { return val, nil } return "", fmt.Errorf("object property not found: %s", property) - + case "rule": if val, ok := ctx.Rule[property]; ok { return val, nil } return "", fmt.Errorf("rule property not found: %s", property) - + case "now": return e.resolveNow(property, ctx.Now) - + case "custom": if val, ok := ctx.Custom[property]; ok { return val, nil } return "", fmt.Errorf("custom property not found: %s", property) - + default: return "", fmt.Errorf("unknown variable source: %s", source) } @@ -159,28 +159,28 @@ func (e *Expander) ListVariables(template string) []string { matches := e.variablePattern.FindAllString(template, -1) unique := make(map[string]bool) var result []string - + for _, m := range matches { if !unique[m] { unique[m] = true result = append(result, m) } } - + return result } // ValidateTemplate checks if a template has valid variable syntax func (e *Expander) ValidateTemplate(template string) []string { var warnings []string - + // Check for unclosed braces openCount := strings.Count(template, "{{") closeCount := strings.Count(template, "}}") - + if openCount != closeCount { warnings = append(warnings, "mismatched variable delimiters") } - + return warnings } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 7998ad2..8a03130 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -136,8 +136,14 @@ func (s *Server) setupRoutes() { s.router.With(api.DocsPageAuthMiddleware(s.deps.AuthService)).Get("/api/docs", openAPIHandler.UI) s.router.With(api.AuthMiddleware(s.deps.AuthService)).Get("/api/docs/openapi.json", openAPIHandler.Spec) + // CSRF guards cookie-authenticated mutations. Bearer/API-key requests are + // not CSRF-reachable and pass straight through, so this is transparent to + // the SPA and to API clients. + csrf := api.NewCSRF(s.config.SecretKey) + // API v1 routes s.router.Route("/api/v1", func(r chi.Router) { + r.Use(csrf.Middleware) // Public system endpoints — discoverable without a session so that // liveness probes, reverse proxies, and the bootstrap flow can // interrogate the server before any user has signed in. @@ -154,7 +160,7 @@ func (s *Server) setupRoutes() { r.Post("/logout", authHandler.Logout) r.With(api.AuthMiddleware(s.deps.AuthService)).Get("/me", authHandler.Me) r.With(api.AuthMiddleware(s.deps.AuthService)).Post("/change-password", authHandler.ChangePassword) - r.Get("/csrf", authHandler.CSRF) + r.Get("/csrf", csrf.Handler) // SSO (OIDC). status/login/callback are public; config is admin-only. r.Get("/oidc/status", oidcHandler.Status) @@ -255,6 +261,10 @@ func (s *Server) setupRoutes() { r.Get("/", backupsHandler.List) r.Post("/", backupsHandler.Create) r.Post("/{id}/restore", backupsHandler.Restore) + // A restore is staged, then applied on the next start; these + // let an operator see and cancel one before it takes effect. + r.Get("/restore", backupsHandler.RestoreStatus) + r.Delete("/restore", backupsHandler.CancelRestore) }) // API Keys — protected by the enclosing group's AuthMiddleware. diff --git a/backend/internal/services/backup_service.go b/backend/internal/services/backup_service.go index b9d4afb..9169861 100644 --- a/backend/internal/services/backup_service.go +++ b/backend/internal/services/backup_service.go @@ -118,30 +118,41 @@ func (s *BackupService) ListBackups() ([]BackupInfo, error) { return backups, nil } -// RestoreBackup restores from a backup file +// RestoreBackup validates a backup and stages it to be applied on the next +// start. It deliberately does not swap the database underneath the running +// server: every repository shares one connection pool, and closing it would +// invalidate those handles process-wide. The staged file is applied by +// db.New before the pool is opened, which is the only safe moment. +// +// A safety backup of the current database is taken first, so the state being +// replaced is always recoverable. func (s *BackupService) RestoreBackup(backupPath string) error { - s.logger.Info("BackupService", "Restoring from backup: %s", backupPath) + s.logger.Info("BackupService", "Staging restore from backup: %s", backupPath) - // Verify backup file exists - if _, err := os.Stat(backupPath); os.IsNotExist(err) { - return fmt.Errorf("backup file not found: %s", backupPath) + if err := db.ValidateBackup(backupPath); err != nil { + return err } - // Create safety backup before restore - _, err := s.CreateBackup("pre-restore", "system") - if err != nil { + if _, err := s.CreateBackup("pre-restore", "system"); err != nil { s.logger.Warn("BackupService", "Failed to create safety backup: %v", err) } - // TODO: Implement actual restore logic - // This would involve: - // 1. Closing current database connection - // 2. Copying backup file to database location - // 3. Reopening database - // 4. Running migrations if needed + if err := db.StageRestore(s.database.Path(), backupPath); err != nil { + return err + } - s.logger.Info("BackupService", "Restore completed from: %s", backupPath) - return fmt.Errorf("restore not yet implemented") + s.logger.Info("BackupService", "Restore staged from %s; it is applied on the next start", backupPath) + return nil +} + +// PendingRestore reports whether a restore is staged and awaiting a restart. +func (s *BackupService) PendingRestore() bool { + return db.PendingRestorePath(s.database.Path()) != "" +} + +// CancelRestore discards a staged restore. +func (s *BackupService) CancelRestore() error { + return db.CancelPendingRestore(s.database.Path()) } func (s *BackupService) recordBackup(backup *BackupInfo, triggeredBy string) error { diff --git a/backend/internal/tlsmgr/manager.go b/backend/internal/tlsmgr/manager.go index e5c57b7..eec1b8d 100644 --- a/backend/internal/tlsmgr/manager.go +++ b/backend/internal/tlsmgr/manager.go @@ -64,6 +64,13 @@ type Manager struct { renewBefore time.Duration logger *logging.Logger + // ensureMu serializes Ensure/Reload. Two callers can reach it at once — the + // renewal loop and the TLS settings handler — and an overlapping run would + // have them issuing leaves, rewriting , and mutating the host + // certificate store underneath each other (the store prune removes any + // superseded leaf, so a racing pair could delete the one just installed). + ensureMu sync.Mutex + mu sync.RWMutex current *tls.Certificate mode string @@ -121,6 +128,9 @@ func (m *Manager) config() Config { // mode. On failure of a non-auto mode it falls back to the self-managed cert so // the server still comes up over HTTPS. func (m *Manager) Ensure() error { + m.ensureMu.Lock() + defer m.ensureMu.Unlock() + cfg := m.config() switch cfg.Mode { case ModeProvided: @@ -183,12 +193,17 @@ func (m *Manager) ensureAuto(pfxPassword string) error { leaf = existing } } + // issued records whether this call actually minted a new leaf, rather than + // reusing the persisted one, so the store-publish step can report a real + // renewal instead of guessing from the certificate's age. + issued := false if leaf == nil { m.logf("Issuing server certificate for CN=%s (SANs: %v)", m.cn, m.dnsNames) leaf, err = pki.IssueLeaf(inter, m.cn, m.dnsNames, m.ips, m.leafValidity) if err != nil { return err } + issued = true } chain := &pki.Chain{Root: root, Intermediate: inter, Leaf: leaf} @@ -209,9 +224,33 @@ func (m *Manager) ensureAuto(pfxPassword string) error { } else { m.logf("Self-managed CA present in the system trust store") } + + // Publish the leaf (with its key) into the host's personal store so other + // software that resolves certificates by store lookup — rather than reading + // our PEM files — can use it. Re-run on every Ensure so a renewed leaf + // replaces the previous one instead of accumulating. + m.installLeafToStore(pfxPassword, chain.Leaf.DER, issued) return nil } +// installLeafToStore imports the current leaf into the host certificate store, +// best-effort. Failure is logged, never fatal: it needs administrative rights, +// and TLS serving does not depend on it. +func (m *Manager) installLeafToStore(pfxPassword string, leafDER []byte, renewed bool) { + pfx, err := os.ReadFile(filepath.Join(m.dir, "server.pfx")) + if err != nil { + m.logf("could not read server.pfx to publish the leaf into the host store: %v", err) + return + } + if err := InstallLeafToMyStore(pfx, pfxPassword, leafDER); err != nil { + m.logf("could not publish the server certificate into the host personal store (%v); TLS still works", err) + return + } + if renewed { + m.logf("Renewed server certificate published to the host personal store (previous one removed)") + } +} + // ensureProvided loads a bring-your-own cert/key (plus optional chain) written // under /provided by the upload API. func (m *Manager) ensureProvided() error { diff --git a/backend/internal/tlsmgr/store_other.go b/backend/internal/tlsmgr/store_other.go index d2163b8..38fb9ac 100644 --- a/backend/internal/tlsmgr/store_other.go +++ b/backend/internal/tlsmgr/store_other.go @@ -2,7 +2,10 @@ package tlsmgr -import "errors" +import ( + "crypto/x509" + "errors" +) // errWindowsOnly is returned when Windows-store operations are attempted on a // non-Windows platform. @@ -18,3 +21,9 @@ 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 } + +// InstallLeafToMyStore is a no-op off Windows (no "My" store to publish into). +func InstallLeafToMyStore(pfxData []byte, password string, leafDER []byte) error { return nil } + +// RemoveLeafFromMyStore is a no-op off Windows. +func RemoveLeafFromMyStore(cert *x509.Certificate) error { return nil } diff --git a/backend/internal/tlsmgr/store_windows.go b/backend/internal/tlsmgr/store_windows.go index 7c3fc1f..3045830 100644 --- a/backend/internal/tlsmgr/store_windows.go +++ b/backend/internal/tlsmgr/store_windows.go @@ -8,6 +8,7 @@ package tlsmgr import ( + "bytes" "crypto" "crypto/rsa" "crypto/sha1" @@ -52,6 +53,19 @@ var ( const ( x509ASNEncoding = 0x00000001 certStoreAddReplaceExisting = 3 + + // PFXImportCertStore flags. The key is persisted to the machine keyset (so + // LocalSystem services can use it) and forced into a CNG KSP, matching the + // ncryptSigner path used when serving from the store. + // + // CRYPT_EXPORTABLE is deliberately NOT set: a non-exportable server key is + // the better posture, and it costs nothing here because the store copy is + // not the source of truth — the same key lives in /tls/server.key and + // server.pfx, so moving the install to another host means copying the data + // directory, not extracting the key from the store. + cryptMachineKeyset = 0x00000020 + pkcs12AlwaysCNGKSP = 0x00000200 + pkcs12AllowOverwriteKey = 0x00004000 ) // InstallTrustAnchors installs the self-managed root and intermediate into the @@ -99,6 +113,167 @@ func addToSystemStore(name string, der []byte) error { return nil } +// InstallLeafToMyStore imports the serving leaf (with its private key) into the +// LocalMachine "My" store, so other software on this host — anything that +// resolves a certificate by store lookup rather than reading our PEM files — +// finds it. pfxData is the PKCS#12 bundle written by pki.Chain.Export, and +// leafDER identifies which certificate in that bundle is the leaf (the bundle +// also carries the CA chain, which belongs in ROOT/CA instead). +// +// Renewal is accounted for: the new leaf is added with REPLACE_EXISTING, then +// any previous certificate with the same subject *and* the same issuer is +// removed, so the store holds exactly one current leaf instead of accumulating +// one per renewal. Only certificates issued by our own CA to our own subject +// are ever deleted. +// +// Requires administrative rights (the Windows service runs as LocalSystem). +func InstallLeafToMyStore(pfxData []byte, password string, leafDER []byte) error { + if len(pfxData) == 0 || len(leafDER) == 0 { + return nil + } + leaf, err := x509.ParseCertificate(leafDER) + if err != nil { + return fmt.Errorf("parsing leaf: %w", err) + } + + pwPtr, err := windows.UTF16PtrFromString(password) + if err != nil { + return err + } + blob := windows.CryptDataBlob{Size: uint32(len(pfxData)), Data: &pfxData[0]} + imported, err := windows.PFXImportCertStore(&blob, pwPtr, + cryptMachineKeyset|pkcs12AllowOverwriteKey|pkcs12AlwaysCNGKSP) + if err != nil { + return fmt.Errorf("importing PFX: %w", err) + } + defer windows.CertCloseStore(imported, 0) + + // Locate the leaf inside the imported bundle by exact DER match. + leafCtx := findContextByDER(imported, leafDER) + if leafCtx == nil { + return fmt.Errorf("leaf certificate not found in the imported PFX") + } + defer windows.CertFreeCertificateContext(leafCtx) + + namePtr, err := windows.UTF16PtrFromString("MY") + if err != nil { + return err + } + // Read-write handle (no readonly flag) — this is the one place we modify My. + my, err := windows.CertOpenStore( + certStoreProvSystemW, 0, 0, certSystemStoreLocalMac, + uintptr(unsafe.Pointer(namePtr)), + ) + if err != nil { + return fmt.Errorf("opening My store: %w", err) + } + defer windows.CertCloseStore(my, 0) + + if err := windows.CertAddCertificateContextToStore(my, leafCtx, certStoreAddReplaceExisting, nil); err != nil { + return fmt.Errorf("adding leaf to My store: %w", err) + } + + pruneSupersededLeaves(my, leaf) + return nil +} + +// RemoveLeafFromMyStore deletes a previously installed leaf (and any sibling +// sharing its subject and issuer) from the LocalMachine "My" store. Used when +// the service is removed, so uninstalling does not leave an orphaned +// certificate behind. Best-effort; a missing certificate is not an error. +func RemoveLeafFromMyStore(cert *x509.Certificate) error { + if cert == nil { + return nil + } + namePtr, err := windows.UTF16PtrFromString("MY") + if err != nil { + return err + } + my, err := windows.CertOpenStore( + certStoreProvSystemW, 0, 0, certSystemStoreLocalMac, + uintptr(unsafe.Pointer(namePtr)), + ) + if err != nil { + return fmt.Errorf("opening My store: %w", err) + } + defer windows.CertCloseStore(my, 0) + + // Delete the certificate itself along with any same-subject/issuer sibling: + // pruneSupersededLeaves keeps anything matching cert.Raw, so pass a copy + // whose Raw can never match a stored entry. + if ctx := findContextByDER(my, cert.Raw); ctx != nil { + _ = windows.CertDeleteCertificateFromStore(ctx) + } + sentinel := *cert + sentinel.Raw = nil + pruneSupersededLeaves(my, &sentinel) + return nil +} + +// findContextByDER returns the context in store whose encoded certificate +// equals der, or nil. The returned context is owned by the caller. +func findContextByDER(store windows.Handle, der []byte) *windows.CertContext { + var prev *windows.CertContext + for { + ctx, err := windows.CertEnumCertificatesInStore(store, prev) + if ctx == nil || err != nil { + return nil + } + if bytes.Equal(contextDER(ctx), der) { + // Duplicate so the enumeration can be torn down without freeing + // the context we are handing back. + dup := windows.CertDuplicateCertificateContext(ctx) + freeEnum(ctx) + return dup + } + prev = ctx + } +} + +// pruneSupersededLeaves removes certificates in store that share the current +// leaf's subject and issuer but not its thumbprint — i.e. leaves this CA issued +// on a previous renewal. Anything issued by another CA, or to another subject, +// is left strictly alone. Best-effort: a failed delete is not fatal. +func pruneSupersededLeaves(store windows.Handle, current *x509.Certificate) { + // Collect first, delete afterwards: CertDeleteCertificateFromStore frees the + // context it is given, which would invalidate the running enumeration. + var doomed []*windows.CertContext + var prev *windows.CertContext + for { + ctx, err := windows.CertEnumCertificatesInStore(store, prev) + if ctx == nil || err != nil { + break + } + der := contextDER(ctx) + if !bytes.Equal(der, current.Raw) { + if c, perr := x509.ParseCertificate(der); perr == nil && + bytes.Equal(c.RawSubject, current.RawSubject) && + bytes.Equal(c.RawIssuer, current.RawIssuer) { + doomed = append(doomed, windows.CertDuplicateCertificateContext(ctx)) + } + } + prev = ctx + } + for _, ctx := range doomed { + // Delete frees the context whether it succeeds or fails. + _ = windows.CertDeleteCertificateFromStore(ctx) + } +} + +// freeEnum releases a certificate context obtained mid-enumeration, when the +// caller stops early instead of enumerating to completion. +// +// CertEnumCertificatesInStore frees the context passed as pPrevCertContext on +// each call and frees the last one when it returns NULL, so a loop that runs to +// completion leaks nothing. Returning early leaves the current context live — +// and because CertCloseStore(store, 0) defers the actual close until every +// outstanding context is released, that leaks the store handle too. +func freeEnum(ctx *windows.CertContext) { + if ctx != nil { + windows.CertFreeCertificateContext(ctx) + } +} + // prevKey holds the NCrypt key handle currently in use so it can be released // when the manager reloads to a new certificate. var ( @@ -204,13 +379,18 @@ func (m *Manager) ensureWindowsStore(thumbprint string) error { continue } // Match. Acquire the CNG key (caller-owned) then release the context - // and store; the key handle stays valid on its own. + // and store; the key handle stays valid on its own. The context must + // be freed explicitly here: we are leaving the enumeration early, so + // CertEnumCertificatesInStore will never free it for us, and the + // store handle would stay open behind it. cert, perr := x509.ParseCertificate(der) if perr != nil { + freeEnum(ctx) windows.CertCloseStore(store, 0) return fmt.Errorf("parsing store certificate: %w", perr) } key, kerr := acquireNCryptKey(ctx) + freeEnum(ctx) windows.CertCloseStore(store, 0) if kerr != nil { return kerr diff --git a/backend/internal/tlsmgr/store_windows_test.go b/backend/internal/tlsmgr/store_windows_test.go new file mode 100644 index 0000000..aac555d --- /dev/null +++ b/backend/internal/tlsmgr/store_windows_test.go @@ -0,0 +1,106 @@ +//go:build windows + +package tlsmgr + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Grace-Solutions/OrchestrAD/internal/pki" + "golang.org/x/sys/windows" +) + +// elevated reports whether this process can write to LocalMachine stores. +func elevated() bool { + var sid *windows.SID + if err := windows.AllocateAndInitializeSid( + &windows.SECURITY_NT_AUTHORITY, 2, + windows.SECURITY_BUILTIN_DOMAIN_RID, windows.DOMAIN_ALIAS_RID_ADMINS, + 0, 0, 0, 0, 0, 0, &sid); err != nil { + return false + } + defer windows.FreeSid(sid) + member, err := windows.Token(0).IsMember(sid) + return err == nil && member +} + +// TestInstallLeafToMyStoreReplacesOnRenewal covers the renewal path: a second +// leaf issued by the same CA to the same subject must supersede the first +// rather than accumulate, and certificates from other issuers must be left +// alone. Skipped without admin rights, since it writes to LocalMachine\My. +func TestInstallLeafToMyStoreReplacesOnRenewal(t *testing.T) { + if !elevated() { + t.Skip("needs administrative rights to write LocalMachine\\My") + } + + dir := t.TempDir() + root, inter, err := pki.NewCA("OrchestrAD Test Root CA", "OrchestrAD Test Intermediate CA") + if err != nil { + t.Fatalf("NewCA: %v", err) + } + + // Unique CN so the test can never collide with a real certificate and its + // pruning can only ever touch its own leaves. + cn := "orchestrad-store-test-" + time.Now().UTC().Format("20060102150405.000000") + + issue := func() *pki.CertKey { + t.Helper() + leaf, err := pki.IssueLeaf(inter, cn, []string{cn}, nil, time.Hour) + if err != nil { + t.Fatalf("IssueLeaf: %v", err) + } + chain := &pki.Chain{Root: root, Intermediate: inter, Leaf: leaf} + if err := chain.Export(dir, "testpw"); err != nil { + t.Fatalf("Export: %v", err) + } + pfx, err := os.ReadFile(filepath.Join(dir, "server.pfx")) + if err != nil { + t.Fatalf("read pfx: %v", err) + } + if err := InstallLeafToMyStore(pfx, "testpw", leaf.DER); err != nil { + t.Fatalf("InstallLeafToMyStore: %v", err) + } + return leaf + } + + countOurs := func() (int, string) { + t.Helper() + certs, err := ListWindowsCerts() + if err != nil { + t.Fatalf("ListWindowsCerts: %v", err) + } + // Match on the CN substring: IssueLeaf also sets an Organization, so the + // rendered subject is "CN=,O=OrchestrAD". + n, thumb := 0, "" + for _, c := range certs { + if strings.Contains(c.Subject, cn) { + n++ + thumb = c.Thumbprint + } + } + return n, thumb + } + + first := issue() + t.Cleanup(func() { _ = RemoveLeafFromMyStore(first.Certificate) }) + + n, firstThumb := countOurs() + if n != 1 { + t.Fatalf("after first install: %d certificates with our CN, want 1", n) + } + + // Renew: a different leaf, same subject and issuer. + second := issue() + t.Cleanup(func() { _ = RemoveLeafFromMyStore(second.Certificate) }) + + n, secondThumb := countOurs() + if n != 1 { + t.Errorf("after renewal: %d certificates with our CN, want 1 (the old one should be pruned)", n) + } + if secondThumb == firstThumb { + t.Errorf("renewal did not replace the stored certificate (thumbprint unchanged)") + } +} diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 888476b..9cb59d2 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -24,16 +24,16 @@ const ( type ConditionOperator string const ( - OperatorEquals ConditionOperator = "Equals" - OperatorNotEquals ConditionOperator = "NotEquals" - OperatorContains ConditionOperator = "Contains" - OperatorStartsWith ConditionOperator = "StartsWith" - OperatorEndsWith ConditionOperator = "EndsWith" - OperatorRegex ConditionOperator = "Regex" - OperatorExists ConditionOperator = "Exists" - OperatorNotExists ConditionOperator = "NotExists" + OperatorEquals ConditionOperator = "Equals" + OperatorNotEquals ConditionOperator = "NotEquals" + OperatorContains ConditionOperator = "Contains" + OperatorStartsWith ConditionOperator = "StartsWith" + OperatorEndsWith ConditionOperator = "EndsWith" + OperatorRegex ConditionOperator = "Regex" + OperatorExists ConditionOperator = "Exists" + OperatorNotExists ConditionOperator = "NotExists" OperatorGreaterThan ConditionOperator = "GreaterThan" - OperatorLessThan ConditionOperator = "LessThan" + OperatorLessThan ConditionOperator = "LessThan" // OperatorMemberOf matches objects that are direct members of a group. OperatorMemberOf ConditionOperator = "MemberOf" // OperatorMemberOfRecursive matches objects that are members of a group @@ -47,10 +47,10 @@ const ( type ActionType string const ( - ActionMoveToOu ActionType = "MoveToOu" - ActionAddToGroup ActionType = "AddToGroup" - ActionAddGroupToGroup ActionType = "AddGroupToGroup" - ActionEnsureGroupExists ActionType = "EnsureGroupExists" + ActionMoveToOu ActionType = "MoveToOu" + ActionAddToGroup ActionType = "AddToGroup" + ActionAddGroupToGroup ActionType = "AddGroupToGroup" + ActionEnsureGroupExists ActionType = "EnsureGroupExists" ActionRemoveFromGroupIfNoMatch ActionType = "RemoveFromGroupIfNoLongerMatched" // ActionSyncGroupMembership reconciles a target group's membership against // the matched object set as a single set operation: add matched objects diff --git a/frontend/src/app/(app)/layout/horizontal/header/Header.tsx b/frontend/src/app/(app)/layout/horizontal/header/Header.tsx index cc027c1..7be73c1 100644 --- a/frontend/src/app/(app)/layout/horizontal/header/Header.tsx +++ b/frontend/src/app/(app)/layout/horizontal/header/Header.tsx @@ -9,8 +9,8 @@ import useMediaQuery from '@mui/material/useMediaQuery'; import { styled } from '@mui/material/styles'; import { IconMenu2 } from "@tabler/icons-react"; +import About from "../../vertical/header/About"; import ApiDocsLink from "../../vertical/header/ApiDocsLink"; -import Notifications from "../../vertical/header/Notification"; import Profile from "../../vertical/header/Profile"; import Search from "../../vertical/header/Search"; import Logo from "../../shared/logo/Logo"; @@ -85,7 +85,7 @@ export default function Header() { )} - + diff --git a/frontend/src/app/(app)/layout/vertical/header/About.tsx b/frontend/src/app/(app)/layout/vertical/header/About.tsx new file mode 100644 index 0000000..27ed723 --- /dev/null +++ b/frontend/src/app/(app)/layout/vertical/header/About.tsx @@ -0,0 +1,134 @@ +"use client"; + +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { Icon } from "@iconify/react"; +import { useEffect, useState } from "react"; + +interface ServerVersion { + version: string; + buildTime: string; + gitCommit: string; +} + +// The /api/v1/version probe predates the {success,data} envelope the rest of +// the API uses and returns bare snake_case JSON, so it is fetched directly +// rather than through the api client (which requires the envelope). Both +// shapes are accepted in case that endpoint is ever normalised. +async function fetchVersion(signal: AbortSignal): Promise { + const res = await fetch("/api/v1/version", { signal }); + if (!res.ok) throw new Error(`version request failed (${res.status})`); + const body = await res.json(); + const v = body?.data ?? body ?? {}; + return { + version: v.version ?? v.Version ?? "unknown", + buildTime: v.build_time ?? v.buildTime ?? "", + gitCommit: v.git_commit ?? v.gitCommit ?? "", + }; +} + +function formatBuildTime(raw: string): string { + if (!raw || raw === "unknown") return "—"; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? raw : d.toLocaleString(); +} + +// About shows the running server's build so an operator can confirm which +// version is deployed — and quote it verbatim in a bug report — without +// shelling onto the host. +export default function About() { + const [open, setOpen] = useState(false); + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + setError(null); + fetchVersion(controller.signal) + .then(setInfo) + .catch((err) => { + if (err?.name !== "AbortError") setError("Could not read the server version."); + }); + return () => controller.abort(); + }, [open]); + + const summary = info + ? `OrchestrAD ${info.version}${info.gitCommit ? ` (${info.gitCommit})` : ""}` + : ""; + + const rows: Array<[string, string]> = info + ? [ + ["Version", info.version], + ["Built", formatBuildTime(info.buildTime)], + ["Commit", info.gitCommit || "—"], + ] + : []; + + return ( + <> + + setOpen(true)}> + + + + + setOpen(false)} fullWidth maxWidth="xs"> + About OrchestrAD + + + Active Directory rule automation. + + + {error && {error}} + + {!info && !error && ( + Reading server version… + )} + + {info && ( + + {rows.map(([label, value]) => ( + + + {label} + + + {value} + + + ))} + + )} + + + + API reference:{" "} + /api/docs + + + + + {info && ( + + )} + + + + + ); +} diff --git a/frontend/src/app/(app)/layout/vertical/header/AppLinks.tsx b/frontend/src/app/(app)/layout/vertical/header/AppLinks.tsx deleted file mode 100644 index c4014f4..0000000 --- a/frontend/src/app/(app)/layout/vertical/header/AppLinks.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import Avatar from '@mui/material/Avatar'; -import Box from '@mui/material/Box'; -import { Grid } from '@mui/material'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; -import * as dropdownData from './data'; -import Link from 'next/link'; -import React from 'react'; - -const AppLinks = () => { - return ( - ( - {dropdownData.appsLink.map((links, index) => ( - - - - - - - - - {links.title} - - - {links.subtext} - - - - - - ))} - ) - ); -}; - -export default AppLinks; diff --git a/frontend/src/app/(app)/layout/vertical/header/Header.tsx b/frontend/src/app/(app)/layout/vertical/header/Header.tsx index 4de1b34..fc884ad 100644 --- a/frontend/src/app/(app)/layout/vertical/header/Header.tsx +++ b/frontend/src/app/(app)/layout/vertical/header/Header.tsx @@ -8,8 +8,8 @@ import { styled } from '@mui/material/styles'; import config from '@/app/context/config' import { useContext } from "react"; import { Icon } from "@iconify/react"; +import About from "./About"; import ApiDocsLink from "./ApiDocsLink"; -import Notifications from "./Notification"; import Profile from "./Profile"; import Search from "./Search"; @@ -88,7 +88,7 @@ const Header = () => { - + diff --git a/frontend/src/app/(app)/layout/vertical/header/Notification.tsx b/frontend/src/app/(app)/layout/vertical/header/Notification.tsx deleted file mode 100644 index e95b4f2..0000000 --- a/frontend/src/app/(app)/layout/vertical/header/Notification.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React, { useState } from "react"; -import Avatar from '@mui/material/Avatar'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Chip from '@mui/material/Chip'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import Typography from '@mui/material/Typography'; -import * as dropdownData from "./data"; -import Scrollbar from "@/app/components/custom-scroll/Scrollbar"; - -import { Icon } from "@iconify/react"; -import { Stack } from "@mui/system"; -import Link from "next/link"; - -const Notifications = () => { - const [anchorEl2, setAnchorEl2] = useState(null); - - const handleClick2 = (event: React.MouseEvent) => { - setAnchorEl2(event.currentTarget); - }; - - const handleClose2 = () => { - setAnchorEl2(null); - }; - - return ( - - - {/* ------------------------------------------- */} - {/* Message Dropdown */} - {/* ------------------------------------------- */} - - - Notifications - - - - {dropdownData.notifications.map((notification, index) => ( - - - - - - - {notification.title} - - - {notification.subtitle} - - - - - - ))} - - - - - - - ); -}; - -export default Notifications; diff --git a/frontend/src/app/(app)/layout/vertical/header/Profile.tsx b/frontend/src/app/(app)/layout/vertical/header/Profile.tsx index 6805748..44fe683 100644 --- a/frontend/src/app/(app)/layout/vertical/header/Profile.tsx +++ b/frontend/src/app/(app)/layout/vertical/header/Profile.tsx @@ -45,7 +45,7 @@ const Profile = () => {