From e0b002975f6465cf66fbb87db74e51ba0bc37170 Mon Sep 17 00:00:00 2001 From: Alphaeus Mote Date: Thu, 3 Sep 2026 10:44:54 -0400 Subject: [PATCH 1/2] feat(api-docs): require auth for docs and add filterable route discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAPI spec and Swagger UI were public. Put them behind the same authentication as the rest of the API, and add a compact route list so a client can ask "what can I call?" without opening dev tools. Access: - /api/openapi.json and /api/routes require a bearer token or API key. - /api/docs additionally accepts a session cookie set at login, so a signed-in operator can open the docs in a new tab; an anonymous browser is redirected to /login?redirect=... and returned afterwards. - The cookie is HttpOnly and path-scoped to /api/docs, so it is never sent to /api/v1/* and cannot authenticate an API call (no CSRF surface). Verified: cookie-only request to /api/v1/rules returns 401. Discovery: both the spec and GET /api/routes accept ?method=get,post and ?path= (comma-separated, case-insensitive). The route list returns method, path, summary, tag, public, and `allowed` — false when a read-scoped API key cannot invoke that route. /api/docs passes the same query through to the spec it loads. UI: a icon in the header (both layouts) and an Administration → API Docs menu entry, opened in a new tab via a new `external` nav-item flag. Co-Authored-By: Claude Opus 4.8 --- README.md | 36 +++ backend/internal/api/auth_handlers.go | 5 + backend/internal/api/docs_cookie.go | 51 ++++ backend/internal/api/docs_cookie_test.go | 57 +++++ backend/internal/api/middleware.go | 36 +++ backend/internal/api/oidc_handlers.go | 1 + backend/internal/api/openapi.go | 222 +++++++++++++++++- backend/internal/api/openapi_test.go | 73 ++++++ backend/internal/server/server.go | 13 +- .../(app)/layout/horizontal/header/Header.tsx | 2 + .../layout/horizontal/navbar/Menudata.ts | 1 + .../horizontal/navbar/NavItem/index.tsx | 15 +- .../layout/vertical/header/ApiDocsLink.tsx | 26 ++ .../(app)/layout/vertical/header/Header.tsx | 2 + .../layout/vertical/sidebar/MenuItems.ts | 8 + .../layout/vertical/sidebar/NavItem/index.tsx | 16 +- frontend/src/app/login/AuthLogin.tsx | 20 +- frontend/src/lib/auth/redirect.ts | 7 + 18 files changed, 568 insertions(+), 23 deletions(-) create mode 100644 backend/internal/api/docs_cookie.go create mode 100644 backend/internal/api/docs_cookie_test.go create mode 100644 frontend/src/app/(app)/layout/vertical/header/ApiDocsLink.tsx diff --git a/README.md b/README.md index d5f9638..f73bcec 100644 --- a/README.md +++ b/README.md @@ -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://:18090/api/docs` * **OpenAPI 3 spec:** `https://:18090/api/openapi.json` +* **Compact route list:** `https://: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 `. +### 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 ` or `X-API-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): diff --git a/backend/internal/api/auth_handlers.go b/backend/internal/api/auth_handlers.go index 2b054cb..5d869e9 100644 --- a/backend/internal/api/auth_handlers.go +++ b/backend/internal/api/auth_handlers.go @@ -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, "") diff --git a/backend/internal/api/docs_cookie.go b/backend/internal/api/docs_cookie.go new file mode 100644 index 0000000..e1aa548 --- /dev/null +++ b/backend/internal/api/docs_cookie.go @@ -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" +} diff --git a/backend/internal/api/docs_cookie_test.go b/backend/internal/api/docs_cookie_test.go new file mode 100644 index 0000000..e963c8f --- /dev/null +++ b/backend/internal/api/docs_cookie_test.go @@ -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) + } +} diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index 18916fb..0b8efbd 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -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 diff --git a/backend/internal/api/oidc_handlers.go b/backend/internal/api/oidc_handlers.go index f2abae9..67b5256 100644 --- a/backend/internal/api/oidc_handlers.go +++ b/backend/internal/api/oidc_handlers.go @@ -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) } diff --git a/backend/internal/api/openapi.go b/backend/internal/api/openapi.go index 34e987b..2764a85 100644 --- a/backend/internal/api/openapi.go +++ b/backend/internal/api/openapi.go @@ -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 `.", + "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.", }, "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 = `