Release: authenticated API docs + route discovery, secret-key diagnostics #13

Merged
gsadmin merged 2 commits from development into main 2026-09-03 15:32:34 +00:00
24 changed files with 799 additions and 29 deletions
+41
View File
@@ -133,9 +133,45 @@ Everything the UI does is available over the REST API, so rules can be created a
* **Interactive docs (Swagger UI):** `https://<host>:18090/api/docs`
* **OpenAPI 3 spec:** `https://<host>:18090/api/openapi.json`
* **Compact route list:** `https://<host>:18090/api/routes`
The spec is **generated from the live router**, so it always reflects the endpoints the running build actually serves. Authenticate at `POST /api/v1/auth/login`, then send the returned token as `Authorization: Bearer <token>`.
### Access to the docs
The documentation endpoints are **not public** — they require the same authentication as the rest of the API:
* **From the dashboard:** sign in, then use the `</>` icon in the header or **Administration → API Docs** in the menu. Login sets a session cookie scoped to `/api/docs`, so the Swagger UI opens straight away in a new tab. Opening `/api/docs` while signed out redirects to the login page and returns you there afterwards.
* **From a client:** send `Authorization: Bearer <token>` or `X-API-Key: <key>` to `/api/openapi.json` or `/api/routes`.
The docs cookie is `HttpOnly` and path-scoped to `/api/docs`, so it is never sent to `/api/v1/*` and cannot be used to make API calls. Swagger's *Try it out* still needs a token or API key.
### Discovering routes
Both `/api/routes` and `/api/openapi.json` accept the same filters, so a client can ask for just the part of the API it cares about:
| Query | Meaning |
| --- | --- |
| `?method=post` | only POST operations |
| `?method=get,post` | GET **or** POST |
| `?path=rules` | paths containing `rules` |
| `?method=post&path=connections` | POST operations on connection routes |
`/api/routes` returns a flat list — method, path, summary, tag, whether the route is public, and `allowed`, which is `false` when a **read-scoped** API key cannot invoke it:
```powershell
$headers = New-Object 'System.Collections.Generic.Dictionary[String,String]'
$headers.Add('X-API-Key', $apiKey)
$routes = Invoke-RestMethod -Method 'Get' `
-Uri 'https://localhost:18090/api/routes?method=post&path=rules' `
-Headers $headers -SkipCertificateCheck
$routes.data.routes | Format-Table -Property 'method', 'path', 'allowed', 'summary'
```
The same filters work on the Swagger UI itself (`/api/docs?path=rules`), which loads a spec narrowed to those operations.
### Create a rule from PowerShell
A ready-to-run example lives at [`docs/examples/Create-OrchestrADRule.ps1`](docs/examples/Create-OrchestrADRule.ps1). It builds the headers and body as strongly-typed dictionaries, serializes the body with `ConvertTo-Json`, and uses full cmdlet names (no aliases):
@@ -294,9 +330,14 @@ Key environment variables (see the CLI help for the full list):
| --- | --- | --- |
| `ORCHESTRAD_DATA_PATH` | `./data` (next to the binary for the service) | Database, logs, backups |
| `ORCHESTRAD_SECRET_KEY` | insecure dev key | AEAD key for credential encryption (**set in production**) |
| `ORCHESTRAD_SECRET_KEY_FILE` | — | Read the secret key from a file instead (for Docker/K8s secrets) |
| `ORCHESTRAD_HOST` / `ORCHESTRAD_PORT` | `0.0.0.0` / `18090` | HTTP bind address |
| `ORCHESTRAD_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
> ⚠️ **The secret key must stay the same for the life of the installation.** Stored credential passwords are encrypted with it, so a changed or lost key leaves them intact but unreadable, and every directory bind fails. Back it up somewhere durable.
>
> On startup OrchestrAD verifies that the stored credentials decrypt with the current key and logs a clear error naming the affected credentials if they do not; `orchestrad doctor` runs the same check on demand. If the original key is genuinely gone, re-enter the affected passwords to re-encrypt them under the new key.
---
## 📦 Build & Versioning
+5
View File
@@ -91,6 +91,10 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
roles[i] = role.Name
}
// Let the signed-in browser open /api/docs directly (cookie is scoped to
// the docs path only; see docs_cookie.go).
SetDocsSessionCookie(w, r, result.SessionToken, result.ExpiresAt)
WriteJSON(w, http.StatusOK, LoginResponse{
Token: result.SessionToken,
ExpiresAt: result.ExpiresAt,
@@ -116,6 +120,7 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if err := h.authService.Logout(token); err != nil {
h.logger.Error("Auth", "Logout failed: %v", err)
}
ClearDocsSessionCookie(w, r)
if user := GetUserFromContext(r.Context()); user != nil {
emitAudit(h.auditService, r, audit.EventLogout, "User", user.ID, "Logout", true, nil, "")
+51
View File
@@ -0,0 +1,51 @@
// Package api - docs session cookie.
//
// The SPA keeps its session token in localStorage and sends it as a bearer
// header, so a plain browser navigation to /api/docs carries no credentials.
// To let a signed-in operator open the interactive docs in a new tab, login
// also sets the session token as an HttpOnly cookie scoped to Path=/api/docs.
// Because of the path scope the cookie is never sent to any /api/v1 route, so
// cookie auth cannot be used for state-changing requests (no CSRF surface);
// Swagger's "Try it out" still needs an explicit bearer token / API key.
package api
import (
"net/http"
"time"
)
// DocsCookieName is the cookie extractToken reads when no header token is present.
const DocsCookieName = "session"
// DocsCookiePath scopes the cookie to the docs pages only.
const DocsCookiePath = "/api/docs"
// SetDocsSessionCookie stores the session token for the docs pages.
func SetDocsSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: DocsCookieName,
Value: token,
Path: DocsCookiePath,
Expires: expires,
HttpOnly: true,
Secure: requestIsSecure(r),
SameSite: http.SameSiteLaxMode,
})
}
// ClearDocsSessionCookie removes the docs cookie (logout).
func ClearDocsSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: DocsCookieName,
Value: "",
Path: DocsCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: requestIsSecure(r),
SameSite: http.SameSiteLaxMode,
})
}
func requestIsSecure(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestDocsCookieScopedToDocsPath guards the security property that the docs
// cookie can never authenticate an /api/v1 request: it must be HttpOnly and
// path-scoped to /api/docs.
func TestDocsCookieScopedToDocsPath(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
req.Header.Set("X-Forwarded-Proto", "https")
SetDocsSessionCookie(rec, req, "tok", time.Now().Add(time.Hour))
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != DocsCookieName || c.Value != "tok" {
t.Errorf("cookie = %s=%s", c.Name, c.Value)
}
if c.Path != DocsCookiePath {
t.Errorf("path = %q, want %q", c.Path, DocsCookiePath)
}
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode {
t.Errorf("cookie flags: httpOnly=%v secure=%v sameSite=%v", c.HttpOnly, c.Secure, c.SameSite)
}
// Clearing expires it on the same path.
rec = httptest.NewRecorder()
ClearDocsSessionCookie(rec, req)
c = rec.Result().Cookies()[0]
if c.MaxAge != -1 || c.Path != DocsCookiePath {
t.Errorf("clear cookie: maxAge=%d path=%q", c.MaxAge, c.Path)
}
}
// TestDocsPageAuthRedirectsAnonymous: an unauthenticated browser hitting the
// docs page is sent to the login page with a return target, not a JSON 401.
func TestDocsPageAuthRedirectsAnonymous(t *testing.T) {
h := DocsPageAuthMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("handler should not run for anonymous request")
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/docs?method=post", nil))
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login?redirect=%2Fapi%2Fdocs%3Fmethod%3Dpost" {
t.Errorf("Location = %q", loc)
}
}
+36
View File
@@ -4,6 +4,7 @@ package api
import (
"context"
"net/http"
"net/url"
"strings"
"github.com/Grace-Solutions/OrchestrAD/internal/auth"
@@ -14,6 +15,10 @@ type contextKey string
const userContextKey contextKey = "user"
// scopeContextKey carries the API key scope ("read"/"readwrite"); empty for a
// session, which has full access.
const scopeContextKey contextKey = "scope"
// AuthMiddleware validates session tokens
func AuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
@@ -36,6 +41,30 @@ func AuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler {
}
ctx := context.WithValue(r.Context(), userContextKey, user)
ctx = context.WithValue(ctx, scopeContextKey, scope)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// DocsPageAuthMiddleware protects the human-facing docs page. Unlike
// AuthMiddleware it answers an unauthenticated browser with a redirect to the
// login page (which returns here afterwards) instead of a JSON 401.
func DocsPageAuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
var user *models.User
var scope string
if token != "" {
user, scope, _ = validateAuth(authService, r, token)
}
if user == nil {
http.Redirect(w, r, "/login?redirect="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
ctx = context.WithValue(ctx, scopeContextKey, scope)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -97,6 +126,13 @@ func GetUserFromContext(ctx context.Context) *models.User {
return user
}
// GetScopeFromContext returns the caller's API key scope ("read"/"readwrite"),
// or "" for a session (full access).
func GetScopeFromContext(ctx context.Context) string {
scope, _ := ctx.Value(scopeContextKey).(string)
return scope
}
// validateAuth resolves the caller to a user and, for API-key auth, the key's
// scope ("read"/"readwrite"; empty for a session, which is full access). A token
// from the X-API-Key header is validated as an API key; otherwise it is
+1
View File
@@ -205,6 +205,7 @@ func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
frag := url.Values{}
frag.Set("oidc_token", result.SessionToken)
frag.Set("expires_at", result.ExpiresAt.Format(time.RFC3339))
SetDocsSessionCookie(w, r, result.SessionToken, result.ExpiresAt)
http.Redirect(w, r, "/login#"+frag.Encode(), http.StatusFound)
}
+210 -12
View File
@@ -18,12 +18,21 @@ import (
"github.com/go-chi/chi/v5"
)
// OpenAPIHandler serves the generated spec and a Swagger UI. The spec is built
// lazily on first request (once the whole router exists) and cached.
// OpenAPIHandler serves the generated spec, a compact route list, and a
// Swagger UI. The spec is built lazily on first request (once the whole router
// exists) and cached; filtered views are derived from the cached document.
//
// All three endpoints require authentication (session or API key). Both the
// spec and the route list accept the same optional filters so a client can ask
// "which POST routes exist?" or "what can I do with rules?":
//
// method=get,post only these HTTP methods (case-insensitive)
// path=rules,connect paths containing any of these substrings (case-insensitive)
type OpenAPIHandler struct {
router chi.Router
once sync.Once
spec []byte
spec map[string]any
full []byte
}
// NewOpenAPIHandler creates a handler that documents the given router.
@@ -31,22 +40,187 @@ func NewOpenAPIHandler(router chi.Router) *OpenAPIHandler {
return &OpenAPIHandler{router: router}
}
// Spec handles GET /api/openapi.json.
func (h *OpenAPIHandler) Spec(w http.ResponseWriter, r *http.Request) {
func (h *OpenAPIHandler) build() {
h.once.Do(func() {
spec := BuildOpenAPISpec(h.router)
h.spec, _ = json.Marshal(spec)
h.spec = BuildOpenAPISpec(h.router)
h.full, _ = json.Marshal(h.spec)
})
}
// Spec handles GET /api/openapi.json (and /api/docs/openapi.json).
func (h *OpenAPIHandler) Spec(w http.ResponseWriter, r *http.Request) {
h.build()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(h.spec)
f := routeFilterFromQuery(r)
if f.empty() {
_, _ = w.Write(h.full)
return
}
out, _ := json.Marshal(FilterSpec(h.spec, f))
_, _ = w.Write(out)
}
// RouteInfo is one entry of the compact route list.
type RouteInfo struct {
Method string `json:"method"`
Path string `json:"path"`
Summary string `json:"summary"`
Tag string `json:"tag"`
Public bool `json:"public"`
// Allowed is false when the caller's API key scope cannot invoke the route
// (a read-scoped key on a mutating method).
Allowed bool `json:"allowed"`
}
// Routes handles GET /api/routes: a flat, filterable list of every documented
// route with its methods — the quick "what can I call?" view for API clients.
func (h *OpenAPIHandler) Routes(w http.ResponseWriter, r *http.Request) {
h.build()
f := routeFilterFromQuery(r)
scope := GetScopeFromContext(r.Context())
routes := ListRoutes(h.spec, f, scope)
q := ""
if !f.empty() {
q = "?" + r.URL.RawQuery
}
WriteJSON(w, http.StatusOK, map[string]any{
"count": len(routes),
"routes": routes,
"docs": "/api/docs" + q,
"openapi": "/api/openapi.json" + q,
"filters": map[string]string{
"method": "comma-separated HTTP methods, e.g. method=get,post",
"path": "comma-separated substrings matched against the path, e.g. path=rules",
},
})
}
// UI handles GET /api/docs — a self-contained Swagger UI page (CDN assets).
// Query filters are passed through to the spec URL so /api/docs?method=post
// shows only the matching operations.
func (h *OpenAPIHandler) UI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(swaggerUIHTML))
}
// RouteFilter narrows the documented routes by method and/or path substring.
type RouteFilter struct {
Methods []string // matched case-insensitively
Paths []string // substrings, matched case-insensitively
}
func (f RouteFilter) empty() bool { return len(f.Methods) == 0 && len(f.Paths) == 0 }
// Matches reports whether a "METHOD /path" pair passes the filter.
func (f RouteFilter) Matches(method, path string) bool {
if len(f.Methods) > 0 && !containsFold(f.Methods, method) {
return false
}
if len(f.Paths) > 0 {
lp := strings.ToLower(path)
hit := false
for _, p := range f.Paths {
if strings.Contains(lp, strings.ToLower(p)) {
hit = true
break
}
}
if !hit {
return false
}
}
return true
}
func routeFilterFromQuery(r *http.Request) RouteFilter {
return RouteFilter{
Methods: splitLower(r.URL.Query().Get("method")),
Paths: splitLower(r.URL.Query().Get("path")),
}
}
func splitLower(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
if p := strings.ToLower(strings.TrimSpace(part)); p != "" {
out = append(out, p)
}
}
return out
}
func containsFold(list []string, s string) bool {
s = strings.ToLower(s)
for _, v := range list {
if v == s {
return true
}
}
return false
}
// FilterSpec returns a copy of spec whose paths contain only the operations
// matching f. Paths left with no operations are dropped.
func FilterSpec(spec map[string]any, f RouteFilter) map[string]any {
out := make(map[string]any, len(spec))
for k, v := range spec {
out[k] = v
}
paths, _ := spec["paths"].(map[string]any)
filtered := map[string]any{}
for p, opsAny := range paths {
ops, _ := opsAny.(map[string]any)
kept := map[string]any{}
for m, op := range ops {
if f.Matches(m, p) {
kept[m] = op
}
}
if len(kept) > 0 {
filtered[p] = kept
}
}
out["paths"] = filtered
return out
}
// ListRoutes flattens the spec into RouteInfo entries matching f, sorted by
// path then method. scope is the caller's API key scope ("" = session).
func ListRoutes(spec map[string]any, f RouteFilter, scope string) []RouteInfo {
paths, _ := spec["paths"].(map[string]any)
var out []RouteInfo
for p, opsAny := range paths {
ops, _ := opsAny.(map[string]any)
for m, opAny := range ops {
if !f.Matches(m, p) {
continue
}
op, _ := opAny.(map[string]any)
method := strings.ToUpper(m)
summary, _ := op["summary"].(string)
tag := ""
if tags, ok := op["tags"].([]any); ok && len(tags) > 0 {
tag, _ = tags[0].(string)
}
out = append(out, RouteInfo{
Method: method,
Path: p,
Summary: summary,
Tag: tag,
Public: publicRoutes[p],
Allowed: scope != "read" || isReadMethod(method),
})
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Path != out[j].Path {
return out[i].Path < out[j].Path
}
return out[i].Method < out[j].Method
})
return out
}
var pathParamRe = regexp.MustCompile(`\{([^}]+)\}`)
// collectionGetRe matches a single-segment collection under /api/v1 (e.g.
@@ -90,7 +264,8 @@ func BuildOpenAPISpec(router chi.Router) map[string]any {
"info": map[string]any{
"title": "OrchestrAD API",
"version": "1",
"description": "Active Directory rule automation. Authenticate at /api/v1/auth/login, then send the returned token as `Authorization: Bearer <token>`.",
"description": "Active Directory rule automation. Authenticate at /api/v1/auth/login, then send the returned token as `Authorization: Bearer <token>` (or use an API key in `X-API-Key`). " +
"This document and GET /api/routes accept `?method=get,post` and `?path=<substring>` filters to narrow the listed operations.",
},
"servers": []any{map[string]any{"url": "/", "description": "This server"}},
"security": []any{
@@ -134,8 +309,6 @@ var publicRoutes = map[string]bool{
"/api/v1/auth/oidc/status": true,
"/api/v1/auth/oidc/login": true,
"/api/v1/auth/oidc/callback": true,
"/api/openapi.json": true,
"/api/docs": true,
}
func operationFor(method, route string) map[string]any {
@@ -257,6 +430,22 @@ var operationRegistry = map[string]map[string]any{
"GET /api/v1/activity": {
"summary": "Action feed (filter by ruleId, category, status, actionType, search)",
},
"GET /api/routes": {
"summary": "Compact list of API routes (filter with method= and path=)",
"parameters": routeFilterParams(),
},
"GET /api/openapi.json": {
"summary": "This OpenAPI document (filter with method= and path=)",
"parameters": routeFilterParams(),
},
"GET /api/docs/openapi.json": {
"summary": "OpenAPI document for the Swagger UI (docs-cookie authenticated)",
"parameters": routeFilterParams(),
},
"GET /api/docs": {
"summary": "Interactive Swagger UI (sign in to the dashboard first, or send a token)",
"parameters": routeFilterParams(),
},
"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")},
@@ -273,6 +462,13 @@ var operationRegistry = map[string]map[string]any{
"POST /api/v1/ad-connections/{id}/query-preview": {"summary": "Run an ad-hoc LDAP query", "requestBody": jsonBody("QueryPreviewInput")},
}
func routeFilterParams() []any {
return []any{
map[string]any{"name": "method", "in": "query", "schema": map[string]any{"type": "string", "example": "get,post"}, "description": "Comma-separated HTTP methods"},
map[string]any{"name": "path", "in": "query", "schema": map[string]any{"type": "string", "example": "rules"}, "description": "Comma-separated substrings matched against the path"},
}
}
func openAPISchemas() map[string]any {
str := map[string]any{"type": "string"}
boolean := map[string]any{"type": "boolean"}
@@ -477,8 +673,10 @@ const swaggerUIHTML = `<!doctype html>
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui-bundle.min.js"></script>
<script>
window.onload = function () {
// The docs cookie is scoped to /api/docs, so fetch the spec from under
// that path; query filters (method=, path=) pass straight through.
window.ui = SwaggerUIBundle({
url: "/api/openapi.json",
url: "/api/docs/openapi.json" + window.location.search,
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis],
layout: "BaseLayout",
+73
View File
@@ -83,6 +83,79 @@ func TestCollectionRoutesNormalizeTrailingSlash(t *testing.T) {
}
}
// TestFilterSpecAndListRoutes covers the method= / path= narrowing shared by
// the spec and the compact route list, plus the read-scope "allowed" flag.
func TestFilterSpecAndListRoutes(t *testing.T) {
r := chi.NewRouter()
noop := func(w http.ResponseWriter, _ *http.Request) {}
r.Route("/api/v1", func(r chi.Router) {
r.Route("/rules", func(r chi.Router) {
r.Get("/", noop)
r.Post("/", noop)
r.Put("/{id}", noop)
r.Delete("/{id}", noop)
})
r.Get("/schedules", noop)
})
spec := BuildOpenAPISpec(r)
// method filter
only := FilterSpec(spec, RouteFilter{Methods: []string{"post", "put"}})["paths"].(map[string]any)
if _, ok := only["/api/v1/schedules"]; ok {
t.Errorf("GET-only path should be dropped by a post,put filter")
}
if ops := only["/api/v1/rules"].(map[string]any); len(ops) != 1 || ops["post"] == nil {
t.Errorf("rules should keep only post, got %v", keysOf(ops))
}
// path substring filter (case-insensitive)
byPath := ListRoutes(spec, RouteFilter{Paths: []string{"RULE"}}, "")
if len(byPath) != 4 {
t.Fatalf("expected 4 rules routes, got %d: %+v", len(byPath), byPath)
}
for _, rt := range byPath {
if !strings.Contains(rt.Path, "rules") {
t.Errorf("unexpected route %s %s", rt.Method, rt.Path)
}
if !rt.Allowed {
t.Errorf("session caller should be allowed on %s %s", rt.Method, rt.Path)
}
}
// sorted by path then method
if byPath[0].Method != "GET" || byPath[1].Method != "POST" {
t.Errorf("expected GET then POST on the collection, got %s, %s", byPath[0].Method, byPath[1].Method)
}
// read-scoped key: mutating routes flagged not allowed
ro := ListRoutes(spec, RouteFilter{}, "read")
for _, rt := range ro {
want := rt.Method == "GET"
if rt.Allowed != want {
t.Errorf("%s %s allowed=%v, want %v for a read key", rt.Method, rt.Path, rt.Allowed, want)
}
}
// empty filter keeps everything
all := ListRoutes(spec, RouteFilter{}, "")
if len(all) != 5 {
t.Errorf("expected 5 routes, got %d", len(all))
}
}
func TestRouteFilterFromQuery(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "/api/routes?method=GET,%20Post&path=rules,%20Conn", nil)
f := routeFilterFromQuery(req)
if len(f.Methods) != 2 || f.Methods[0] != "get" || f.Methods[1] != "post" {
t.Errorf("methods = %v", f.Methods)
}
if len(f.Paths) != 2 || f.Paths[1] != "conn" {
t.Errorf("paths = %v", f.Paths)
}
if !f.Matches("POST", "/api/v1/ad-connections") || f.Matches("DELETE", "/api/v1/rules/{id}") {
t.Errorf("filter matching wrong")
}
}
func keysOf(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
+30 -2
View File
@@ -101,6 +101,11 @@ func runServer(ctx context.Context) error {
return fmt.Errorf("initializing encryptor: %w", err)
}
// 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.
services.LogSecretKeyCheck(database.Conn(), encryptor, logger)
// Build services, repositories, and the execution pipeline
connService := services.NewConnectionService(database.Conn(), encryptor, logger)
ruleRunner := runner.New(database.Conn(), connService, logger)
@@ -269,16 +274,39 @@ func RunDoctor() error {
logger.Error("Doctor", "Database check failed: %v", err)
} else {
logger.Info("Doctor", "Database: OK")
database.Close()
}
// Check secret key
// Check secret key length
if len(cfg.SecretKey) < 32 {
logger.Warn("Doctor", "Secret key should be at least 32 bytes")
} else {
logger.Info("Doctor", "Secret key: OK")
}
// Check that the secret key actually opens the stored credentials — a
// correctly-sized but *different* key passes the length check above and
// still breaks every directory bind.
if database != nil {
keyHash := sha256.Sum256(cfg.SecretKey)
if encryptor, encErr := crypto.NewEncryptor(keyHash[:]); encErr == nil {
res, checkErr := services.CheckSecretKey(database.Conn(), encryptor)
switch {
case checkErr != nil:
logger.Warn("Doctor", "Credential decryption check failed to run: %v", checkErr)
case res.Total == 0:
logger.Info("Doctor", "Credential decryption: no stored secrets to check")
case res.OK():
logger.Info("Doctor", "Credential decryption: OK (%d secret(s))", res.Total)
default:
logger.Error("Doctor",
"Credential decryption: %d of %d secret(s) cannot be decrypted with the current ORCHESTRAD_SECRET_KEY: %v. "+
"Restore the original key, or re-enter these passwords.",
res.Undecryptable, res.Total, res.Failed)
}
}
database.Close()
}
logger.Info("Doctor", "Health checks complete")
return nil
}
+9 -1
View File
@@ -18,7 +18,15 @@ import (
var (
ErrInvalidCiphertext = errors.New("invalid ciphertext")
ErrDecryptionFailed = errors.New("decryption failed")
// ErrDecryptionFailed is returned when AES-GCM authentication fails on a
// well-formed ciphertext. The overwhelmingly common cause is that the data
// was encrypted under a different ORCHESTRAD_SECRET_KEY, so the message
// says so: the alternative (tampered/corrupted bytes) is rare, and an
// operator who has changed or lost the key otherwise gets no clue why an
// unrelated-looking operation now fails.
ErrDecryptionFailed = errors.New(
"decryption failed - the data was encrypted with a different ORCHESTRAD_SECRET_KEY " +
"(the key must stay the same for stored secrets to be readable), or the stored value is corrupted")
)
// Argon2 parameters for password hashing
+9 -4
View File
@@ -125,11 +125,16 @@ func (s *Server) setupRoutes() {
s.router.Head("/api/health", s.handleHealth)
// API documentation: an OpenAPI 3 spec generated from this router (so it
// stays in sync) and a Swagger UI to browse and try it. Public so tooling
// can read the schema before authenticating.
// stays in sync), a compact route list, and a Swagger UI. All require
// authentication. The machine endpoints take a bearer token / API key; the
// docs page (and the spec copy under it) also accept the path-scoped
// session cookie set at login, so a signed-in operator can open it directly
// and an anonymous browser is bounced to the login page.
openAPIHandler := api.NewOpenAPIHandler(s.router)
s.router.Get("/api/openapi.json", openAPIHandler.Spec)
s.router.Get("/api/docs", openAPIHandler.UI)
s.router.With(api.AuthMiddleware(s.deps.AuthService)).Get("/api/openapi.json", openAPIHandler.Spec)
s.router.With(api.AuthMiddleware(s.deps.AuthService)).Get("/api/routes", openAPIHandler.Routes)
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)
// API v1 routes
s.router.Route("/api/v1", func(r chi.Router) {
@@ -191,7 +191,7 @@ func (s *ConnectionService) BuildLDAPConfig(conn *models.ADConnection) (*ldap.Co
if cred.EncryptedSecret != nil {
password, err := s.encryptor.Decrypt(*cred.EncryptedSecret)
if err != nil {
return nil, fmt.Errorf("failed to decrypt credential: %w", err)
return nil, fmt.Errorf("failed to decrypt credential %q: %w", cred.Name, err)
}
config.BindPassword = string(password)
}
@@ -194,7 +194,7 @@ func (s *CredentialService) Test(id string, input TestCredentialInput) (*Credent
if cred.EncryptedSecret != nil {
decrypted, err := s.encryptor.Decrypt(*cred.EncryptedSecret)
if err != nil {
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
return nil, fmt.Errorf("failed to decrypt credential %q: %w", cred.Name, err)
}
password = string(decrypted)
}
@@ -262,7 +262,7 @@ func (s *CredentialService) DecryptPassword(id string) (string, error) {
decrypted, err := s.encryptor.Decrypt(*cred.EncryptedSecret)
if err != nil {
return "", fmt.Errorf("failed to decrypt secret: %w", err)
return "", fmt.Errorf("failed to decrypt credential %q: %w", cred.Name, err)
}
return string(decrypted), nil
@@ -0,0 +1,89 @@
// Package services - secret key health check.
//
// Stored credential secrets are encrypted with a key derived from
// ORCHESTRAD_SECRET_KEY. If that value changes (or is lost and replaced), the
// secrets are still present but can no longer be decrypted, and the failure
// only surfaces later as an opaque error deep inside an unrelated operation —
// a rule preview, a connection test. Checking at startup turns that into one
// clear warning at the moment the wrong key is first used.
package services
import (
"database/sql"
"errors"
"fmt"
"github.com/Grace-Solutions/OrchestrAD/internal/crypto"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
)
// SecretKeyCheckResult reports how many stored credential secrets could and
// could not be decrypted with the current key.
type SecretKeyCheckResult struct {
Total int
Undecryptable int
// Names of the credentials that failed, for the operator to act on.
Failed []string
}
// OK reports whether every stored secret decrypted.
func (r SecretKeyCheckResult) OK() bool { return r.Undecryptable == 0 }
// CheckSecretKey attempts to decrypt every stored credential secret with the
// configured encryptor. Credentials without a secret are skipped. A decryption
// failure is not fatal — the server still starts, since an operator may be
// mid-migration or may simply need to re-enter the secrets.
func CheckSecretKey(db *sql.DB, encryptor *crypto.Encryptor) (SecretKeyCheckResult, error) {
var res SecretKeyCheckResult
rows, err := db.Query(`
SELECT name, encrypted_secret FROM credentials
WHERE deleted_utc IS NULL AND encrypted_secret IS NOT NULL AND encrypted_secret != ''
`)
if err != nil {
return res, err
}
defer rows.Close()
for rows.Next() {
var name string
var secret string
if err := rows.Scan(&name, &secret); err != nil {
return res, err
}
res.Total++
if _, err := encryptor.Decrypt(secret); err != nil {
res.Undecryptable++
if errors.Is(err, crypto.ErrDecryptionFailed) || errors.Is(err, crypto.ErrInvalidCiphertext) {
res.Failed = append(res.Failed, name)
} else {
res.Failed = append(res.Failed, fmt.Sprintf("%s (%v)", name, err))
}
}
}
return res, rows.Err()
}
// LogSecretKeyCheck runs CheckSecretKey and logs the outcome. It is called at
// startup so a mismatched key is reported once, loudly, instead of surfacing
// later as a confusing failure in whatever operation happens to need a bind.
func LogSecretKeyCheck(db *sql.DB, encryptor *crypto.Encryptor, logger *logging.Logger) SecretKeyCheckResult {
res, err := CheckSecretKey(db, encryptor)
if err != nil {
logger.Warn("SecretKey", "Could not verify stored credentials: %v", err)
return res
}
if res.Total == 0 {
return res
}
if res.OK() {
logger.Info("SecretKey", "Verified %d stored credential secret(s) decrypt with the current key", res.Total)
return res
}
logger.Error("SecretKey",
"%d of %d stored credential secret(s) CANNOT be decrypted with the current ORCHESTRAD_SECRET_KEY: %v. "+
"This usually means the secret key changed since the credentials were saved. "+
"Restore the original key, or re-enter the passwords for these credentials.",
res.Undecryptable, res.Total, res.Failed)
return res
}
@@ -0,0 +1,95 @@
package services
import (
"crypto/sha256"
"path/filepath"
"strings"
"testing"
"github.com/Grace-Solutions/OrchestrAD/internal/config"
"github.com/Grace-Solutions/OrchestrAD/internal/crypto"
"github.com/Grace-Solutions/OrchestrAD/internal/db"
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
)
func encryptorFor(t *testing.T, secret string) *crypto.Encryptor {
t.Helper()
sum := sha256.Sum256([]byte(secret))
enc, err := crypto.NewEncryptor(sum[:])
if err != nil {
t.Fatalf("NewEncryptor: %v", err)
}
return enc
}
// TestCheckSecretKeyDetectsChangedKey is the regression guard for the failure
// mode where ORCHESTRAD_SECRET_KEY changes: the stored secret is intact but no
// longer decryptable, and the operator needs to be told exactly that.
func TestCheckSecretKeyDetectsChangedKey(t *testing.T) {
database, err := db.New(config.DatabaseConfig{
Path: filepath.Join(t.TempDir(), "secret_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()
original := encryptorFor(t, "the-original-secret-key-value-32bytes")
secret, err := original.Encrypt([]byte("s3cr3t"))
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if _, err := conn.Exec(
`INSERT INTO credentials (id, name, credential_type, username, encrypted_secret, is_enabled, created_utc, updated_utc)
VALUES ('c1', 'svc-bind', 'UsernamePassword', 'CORP\svc', ?, 1, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
secret); err != nil {
t.Fatalf("insert credential: %v", err)
}
// Same key: everything decrypts.
res, err := CheckSecretKey(conn, original)
if err != nil {
t.Fatalf("CheckSecretKey: %v", err)
}
if !res.OK() || res.Total != 1 {
t.Fatalf("with the original key: total=%d undecryptable=%d", res.Total, res.Undecryptable)
}
// Different key: reported as undecryptable, and the credential is named so
// the operator knows which passwords to restore or re-enter.
res, err = CheckSecretKey(conn, encryptorFor(t, "a-completely-different-secret-key"))
if err != nil {
t.Fatalf("CheckSecretKey: %v", err)
}
if res.OK() {
t.Fatal("expected the check to fail with a different key")
}
if res.Total != 1 || res.Undecryptable != 1 {
t.Errorf("total=%d undecryptable=%d, want 1/1", res.Total, res.Undecryptable)
}
if len(res.Failed) != 1 || !strings.Contains(res.Failed[0], "svc-bind") {
t.Errorf("failed names = %v, want the credential name", res.Failed)
}
}
// TestDecryptionErrorNamesSecretKey keeps the user-facing wording pointing at
// the real cause; this string is what an operator sees in an API error.
func TestDecryptionErrorNamesSecretKey(t *testing.T) {
enc := encryptorFor(t, "key-one-key-one-key-one-key-one!")
ciphertext, err := enc.Encrypt([]byte("value"))
if err != nil {
t.Fatalf("encrypt: %v", err)
}
_, err = encryptorFor(t, "key-two-key-two-key-two-key-two!").Decrypt(ciphertext)
if err == nil {
t.Fatal("expected decryption to fail under a different key")
}
if !strings.Contains(err.Error(), "ORCHESTRAD_SECRET_KEY") {
t.Errorf("error %q should name ORCHESTRAD_SECRET_KEY", err)
}
}
@@ -9,6 +9,7 @@ import useMediaQuery from '@mui/material/useMediaQuery';
import { styled } from '@mui/material/styles';
import { IconMenu2 } from "@tabler/icons-react";
import ApiDocsLink from "../../vertical/header/ApiDocsLink";
import Notifications from "../../vertical/header/Notification";
import Profile from "../../vertical/header/Profile";
import Search from "../../vertical/header/Search";
@@ -83,6 +84,7 @@ export default function Header() {
<Icon icon="solar:sun-2-line-duotone" width="21" height="21" onClick={() => setActiveMode("light")} />
)}
</IconButton>
<ApiDocsLink />
<Notifications />
<Profile />
</Stack>
@@ -45,6 +45,7 @@ const Menuitems = [
{ id: uniqueId(), title: 'Audit Log', href: '/audit' },
{ id: uniqueId(), title: 'Settings', href: '/settings' },
{ id: uniqueId(), title: 'Config Import / Export', href: '/config' },
{ id: uniqueId(), title: 'API Docs', href: '/api/docs', external: true },
],
},
];
@@ -50,9 +50,20 @@ const NavItem = ({ item, level, pathDirect, onClick }: ItemType) => {
// External entries (server-rendered pages such as /api/docs) bypass the
// client router and open in a new tab.
const LinkWrapper = ({ children }: { children: React.ReactNode }) =>
item.external ? (
<a href={`${item?.href}`} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none", color: "inherit" }}>
{children}
</a>
) : (
<Link href={`${item?.href}`}>{children}</Link>
);
return (
<List component="li" disablePadding key={item.id}>
<Link href={`${item?.href}`}>
<LinkWrapper>
<ListItemStyled2
disabled={item?.disabled}
selected={pathDirect === item?.href}
@@ -87,7 +98,7 @@ const NavItem = ({ item, level, pathDirect, onClick }: ItemType) => {
</ListItemIcon>
<ListItemText>{item.title}</ListItemText>
</ListItemStyled2>
</Link>
</LinkWrapper>
</List>
);
};
@@ -0,0 +1,26 @@
"use client";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import { Icon } from "@iconify/react";
// ApiDocsLink opens the server-rendered Swagger UI in a new tab. The page is
// authenticated by the docs cookie set at login, so a signed-in operator lands
// straight on the docs.
export default function ApiDocsLink() {
return (
<Tooltip title="API documentation">
<IconButton
size="large"
color="inherit"
component="a"
href="/api/docs"
target="_blank"
rel="noopener noreferrer"
aria-label="API documentation"
>
<Icon icon="solar:code-square-line-duotone" width="21" height="21" />
</IconButton>
</Tooltip>
);
}
@@ -8,6 +8,7 @@ import { styled } from '@mui/material/styles';
import config from '@/app/context/config'
import { useContext } from "react";
import { Icon } from "@iconify/react";
import ApiDocsLink from "./ApiDocsLink";
import Notifications from "./Notification";
import Profile from "./Profile";
import Search from "./Search";
@@ -86,6 +87,7 @@ const Header = () => {
)}
</IconButton>
<ApiDocsLink />
<Notifications />
<Profile />
</Stack>
@@ -116,6 +116,14 @@ const Menuitems: NavGroup[] = [
href: "/config",
bgcolor: "error",
},
{
id: uniqueId(),
title: "API Docs",
icon: "code-square-line-duotone",
href: "/api/docs",
bgcolor: "secondary",
external: true,
},
];
export default Menuitems;
@@ -99,9 +99,21 @@ export default function NavItem({
// External entries (e.g. the server-rendered API docs) are plain anchors
// opened in a new tab; Next's client-side router would 404 on them in the
// static export.
const LinkWrapper = ({ children }: { children: React.ReactNode }) =>
item.external ? (
<a href={item.href || ''} target="_blank" rel="noopener noreferrer" style={{ textDecoration: "none", color: "inherit" }}>
{children}
</a>
) : (
<Link href={item.href || ''}>{children}</Link>
);
return (
<List component="li" disablePadding key={item?.id && item.title}>
<Link href={item.href || ''}>
<LinkWrapper>
<ListItemStyled
disabled={item?.disabled}
selected={pathDirect === item?.href}
@@ -185,7 +197,7 @@ export default function NavItem({
/>
)}
</ListItemStyled>
</Link>
</LinkWrapper>
</List >
);
}
+17 -3
View File
@@ -11,7 +11,7 @@ import { useContext, useEffect, useState, ReactNode } from "react";
import CustomTextField from "@/app/components/forms/theme-elements/CustomTextField";
import CustomFormLabel from "@/app/components/forms/theme-elements/CustomFormLabel";
import { AuthContext, isApiError } from "@/app/context/AuthContext";
import { safeRedirectTarget } from "@/lib/auth/redirect";
import { isServerRenderedPath, safeRedirectTarget } from "@/lib/auth/redirect";
interface loginType {
title?: string;
@@ -29,6 +29,16 @@ const AuthLogin = ({ title, subtitle, subtext }: loginType) => {
const [error, setError] = useState<string | null>(null);
const [ssoEnabled, setSsoEnabled] = useState(false);
// Continue to the originally requested page. Backend-served pages (the API
// docs) get a full navigation so the docs cookie set at login is used.
const continueTo = (target: string) => {
if (isServerRenderedPath(target)) {
window.location.assign(target);
} else {
router.replace(target);
}
};
// Is SSO configured? Controls whether the "Sign in with SSO" button shows.
useEffect(() => {
let active = true;
@@ -61,7 +71,11 @@ const AuthLogin = ({ title, subtitle, subtext }: loginType) => {
setSubmitting(true);
adoptSession(token, expiresAt)
.then((user) => {
router.replace(user.passwordResetRequired ? "/change-password" : safeRedirectTarget(params?.get("redirect")));
if (user.passwordResetRequired) {
router.replace("/change-password");
} else {
continueTo(safeRedirectTarget(params?.get("redirect")));
}
})
.catch(() => {
setSubmitting(false);
@@ -79,7 +93,7 @@ const AuthLogin = ({ title, subtitle, subtext }: loginType) => {
if (result.user.passwordResetRequired) {
router.replace("/change-password");
} else {
router.replace(safeRedirectTarget(params?.get("redirect")));
continueTo(safeRedirectTarget(params?.get("redirect")));
}
} catch (err) {
if (isApiError(err)) {
+7
View File
@@ -36,3 +36,10 @@ export function sanitizeRedirect(raw: string | null | undefined): string | null
export function safeRedirectTarget(raw: string | null | undefined): string {
return sanitizeRedirect(raw) ?? "/";
}
// isServerRenderedPath reports whether a redirect target is served by the Go
// backend rather than the SPA (e.g. /api/docs). Those need a full browser
// navigation — the client router has no route for them.
export function isServerRenderedPath(target: string): boolean {
return target === "/api" || target.startsWith("/api/");
}