Release: authenticated API docs + route discovery, secret-key diagnostics #13
@@ -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
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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/");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user