Files
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
Addresses the gaps identified in the last audit.

Restore (was a stub returning "not yet implemented"). Every repository shares
one connection pool, so the database cannot be swapped underneath a live
server. Restore is therefore two-phase: RestoreBackup validates the file and
stages it beside the database; db.New applies it before the pool is opened,
which is the only safe moment. The database being replaced is preserved as
<db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot
replay the old journal over the restored file. Validation is strict — SQLite
integrity_check plus a schema probe — because applying an unrelated file
would destroy the install. GET/DELETE /api/v1/backups/restore inspect and
cancel a staged restore. The CLI does both phases at once, since it runs
standalone; `orchestrad backup` was also a stub and now works.

Secret key. With nothing configured the key is generated once and persisted
to <data>/secret.key, so restarts reuse it and moving the stack to another
server is a matter of copying the data directory. Upgrades are handled: if a
database already exists the install was silently running on the legacy
built-in default, so that value is adopted and written out rather than
replaced — generating a fresh key there would make every stored credential
undecryptable. The file is owner-only (ACL-restricted on Windows).

Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the
architectures the release binaries already covered. The Dockerfile
cross-compiles via TARGETARCH rather than emulating, so arm64 costs little.

CSRF: the middleware previously checked only that a header was *present* and
was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens
are now nonce + HMAC-SHA256 signed with the application secret, validated
properly, and the middleware is mounted on /api/v1. Bearer and API-key
requests are not CSRF-reachable and pass through untouched, so this is
transparent to the SPA and to API clients.

Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not
the source of truth — <data>/tls holds the key, so portability is unaffected
and a non-exportable server key is the better posture), the PFX password is
written to server.pfx.password beside the bundle so an operator importing it
by hand does not have to hunt for a password they never chose, and the
"renewed" log line now reflects whether a leaf was actually issued instead of
guessing from its age.

Verified live: backup -> stage -> restart applies and preserves the previous
database; secret key generated, adopted, and read back across restarts with
the credential check confirming decryptability; CSRF endpoint issues real
signed tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:57:15 -04:00

688 lines
25 KiB
Go

// Package api - OpenAPI (Swagger) spec generated from the live router.
//
// The path set is produced by walking the actual chi router, so new routes
// appear in the spec automatically and it cannot drift out of sync. A small
// registry supplies rich summaries, request bodies, and response schemas for
// the automation-critical operations (auth, rules, connection introspection);
// every other route is documented generically from its method and path.
package api
import (
"encoding/json"
"net/http"
"regexp"
"sort"
"strings"
"sync"
"github.com/go-chi/chi/v5"
)
// 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 map[string]any
full []byte
}
// NewOpenAPIHandler creates a handler that documents the given router.
func NewOpenAPIHandler(router chi.Router) *OpenAPIHandler {
return &OpenAPIHandler{router: router}
}
func (h *OpenAPIHandler) build() {
h.once.Do(func() {
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")
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.
// /api/v1/rules), which take pagination query parameters.
var collectionGetRe = regexp.MustCompile(`^/api/v1/[a-z0-9-]+$`)
// BuildOpenAPISpec walks the router and assembles an OpenAPI 3.0 document.
func BuildOpenAPISpec(router chi.Router) map[string]any {
paths := map[string]map[string]any{}
_ = chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
// Only document the JSON API surface and the health/version probes.
if !strings.HasPrefix(route, "/api/") && route != "/health" {
return nil
}
route = strings.TrimSuffix(route, "/*")
// chi emits collection roots registered as "/" with a trailing slash
// (e.g. /api/v1/rules/); normalize so registry keys and the pagination
// matcher line up and the documented path is clean.
if len(route) > 1 {
route = strings.TrimRight(route, "/")
}
if route == "" {
return nil
}
if paths[route] == nil {
paths[route] = map[string]any{}
}
paths[route][strings.ToLower(method)] = operationFor(method, route)
return nil
})
// map[string]map[string]any -> map[string]any for JSON.
pathsOut := make(map[string]any, len(paths))
for p, ops := range paths {
pathsOut[p] = ops
}
return map[string]any{
"openapi": "3.0.3",
"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>` (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{
map[string]any{"bearerAuth": []any{}},
map[string]any{"apiKeyAuth": []any{}},
},
"paths": pathsOut,
"components": map[string]any{
"securitySchemes": map[string]any{
"bearerAuth": map[string]any{
"type": "http", "scheme": "bearer",
"description": "Session token from POST /api/v1/auth/login, sent as `Authorization: Bearer <token>`.",
},
"apiKeyAuth": map[string]any{
"type": "apiKey", "in": "header", "name": "X-API-Key",
"description": "An API key. Read-scoped keys may call GET only.",
},
},
"schemas": openAPISchemas(),
},
}
}
// listQueryParams documents the standard pagination query parameters shared by
// the collection GET endpoints.
func listQueryParams() []any {
return []any{
map[string]any{"name": "page", "in": "query", "schema": map[string]any{"type": "integer", "default": 1}},
map[string]any{"name": "pageSize", "in": "query", "schema": map[string]any{"type": "integer", "default": 20, "maximum": 100}},
}
}
// publicRoutes need no auth; their operations advertise empty security.
var publicRoutes = map[string]bool{
"/health": true,
"/api/health": true,
"/api/v1/health": true,
"/api/v1/version": true,
"/api/v1/auth/login": true,
"/api/v1/auth/csrf": true,
"/api/v1/auth/oidc/status": true,
"/api/v1/auth/oidc/login": true,
"/api/v1/auth/oidc/callback": true,
}
func operationFor(method, route string) map[string]any {
op := map[string]any{
"tags": []any{tagFor(route)},
"responses": defaultResponses(),
}
// Path parameters.
var params []any
for _, m := range pathParamRe.FindAllStringSubmatch(route, -1) {
params = append(params, map[string]any{
"name": m[1], "in": "path", "required": true,
"schema": map[string]any{"type": "string"},
})
}
if len(params) > 0 {
op["parameters"] = params
} else if method == http.MethodGet && collectionGetRe.MatchString(route) && route != "/api/v1/version" && route != "/api/v1/health" {
// Collection GET endpoints accept standard pagination.
op["parameters"] = listQueryParams()
}
if publicRoutes[route] {
op["security"] = []any{}
}
if meta, ok := operationRegistry[method+" "+route]; ok {
for k, v := range meta {
op[k] = v
}
} else {
op["summary"] = strings.ToUpper(method) + " " + route
}
return op
}
func tagFor(route string) string {
parts := strings.Split(strings.TrimPrefix(route, "/api/v1/"), "/")
if len(parts) == 0 || parts[0] == "" {
return "system"
}
return parts[0]
}
func defaultResponses() map[string]any {
return map[string]any{
"200": map[string]any{"description": "OK"},
"400": map[string]any{"description": "Bad request"},
"401": map[string]any{"description": "Unauthorized"},
}
}
func jsonBody(schemaRef string) map[string]any {
return map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{"$ref": "#/components/schemas/" + schemaRef},
},
},
}
}
// operationRegistry supplies rich detail for the operations most useful to
// automate. Everything else is documented generically.
var operationRegistry = map[string]map[string]any{
"POST /api/v1/auth/login": {
"summary": "Log in and obtain a bearer token",
"security": []any{},
"requestBody": jsonBody("LoginRequest"),
"responses": map[string]any{
"200": map[string]any{"description": "Token issued"},
"401": map[string]any{"description": "Invalid credentials"},
},
},
"GET /api/v1/rules/metadata": {
"summary": "Vocabulary for building rules (object types, operators, action types, sync modes, common attributes)",
},
"POST /api/v1/rules": {
"summary": "Create a rule (dynamic group)",
"requestBody": jsonBody("RuleInput"),
},
"PUT /api/v1/rules/{id}": {
"summary": "Update a rule; conditionGroups/actions replace the rule's logic when present",
"requestBody": jsonBody("RuleInput"),
},
"POST /api/v1/rules/preview": {
"summary": "Preview an unsaved rule draft (matched objects + planned +add/-remove)",
"requestBody": jsonBody("RuleInput"),
},
"POST /api/v1/rules/{id}/run": {
"summary": "Run a rule now",
},
"GET /api/v1/ad-connections/{id}/directory": {
"summary": "Search groups / OUs on the connection (for target pickers)",
"parameters": []any{
map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
map[string]any{"name": "type", "in": "query", "schema": map[string]any{"type": "string", "enum": []any{"Group", "OU", "User", "Computer"}}},
map[string]any{"name": "q", "in": "query", "schema": map[string]any{"type": "string"}},
},
},
"GET /api/v1/ad-connections/{id}/attributes": {
"summary": "Schema attributes applicable to an object type (filter builder)",
"parameters": []any{
map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
map[string]any{"name": "objectType", "in": "query", "schema": map[string]any{"type": "string", "enum": []any{"User", "Computer", "Group"}}},
map[string]any{"name": "q", "in": "query", "schema": map[string]any{"type": "string"}},
},
},
"GET /api/v1/ad-connections/{id}/attribute-values": {
"summary": "Distinct values present for an attribute (value autocomplete)",
"parameters": []any{
map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}},
map[string]any{"name": "attribute", "in": "query", "required": true, "schema": map[string]any{"type": "string"}},
map[string]any{"name": "objectType", "in": "query", "schema": map[string]any{"type": "string"}},
map[string]any{"name": "q", "in": "query", "schema": map[string]any{"type": "string"}},
},
},
"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")},
"PUT /api/v1/credentials/{id}": {"summary": "Update a credential", "requestBody": jsonBody("CredentialInput")},
"POST /api/v1/schedules": {"summary": "Create a schedule", "requestBody": jsonBody("ScheduleInput")},
"PUT /api/v1/schedules/{id}": {"summary": "Update a schedule", "requestBody": jsonBody("ScheduleInput")},
"POST /api/v1/api-keys": {"summary": "Create an API key (returns the key once)", "requestBody": jsonBody("APIKeyInput")},
"POST /api/v1/users": {"summary": "Create a user", "requestBody": jsonBody("UserInput")},
"PUT /api/v1/users/{id}": {"summary": "Update a user", "requestBody": jsonBody("UserInput")},
"PUT /api/v1/settings/{key}": {"summary": "Create or update a setting", "requestBody": jsonBody("SettingInput")},
"POST /api/v1/tls/mode": {"summary": "Set the TLS certificate source", "requestBody": jsonBody("TLSModeInput")},
"POST /api/v1/tls/certificate": {"summary": "Upload a bring-your-own certificate", "requestBody": jsonBody("TLSCertificateInput")},
"POST /api/v1/config/import": {"summary": "Import configuration (wrapped {payload,dryRun} or a bare exported config)", "requestBody": jsonBody("ConfigImport")},
"POST /api/v1/ad-connections/{id}/query-preview": {"summary": "Run an ad-hoc LDAP query", "requestBody": jsonBody("QueryPreviewInput")},
}
func 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"}
return map[string]any{
"LoginRequest": map[string]any{
"type": "object",
"required": []any{"username", "password"},
"properties": map[string]any{
"username": str,
"password": str,
},
},
"RuleConditionInput": map[string]any{
"type": "object",
"required": []any{"attributeName", "operator"},
"properties": map[string]any{
"attributeName": map[string]any{"type": "string", "example": "department"},
"operator": map[string]any{"type": "string", "example": "Equals", "description": "See /rules/metadata operators"},
"comparisonValue": map[string]any{"type": "string", "example": "Sales"},
"customLdapExpression": map[string]any{"type": "string", "description": "Used when operator is CustomLdap"},
"negate": boolean,
"isEnabled": boolean,
},
},
"RuleConditionGroupInput": map[string]any{
"type": "object",
"properties": map[string]any{
"name": str,
"joinOperator": map[string]any{"type": "string", "enum": []any{"AND", "OR"}},
"negate": boolean,
"isEnabled": boolean,
"conditions": map[string]any{
"type": "array",
"items": map[string]any{"$ref": "#/components/schemas/RuleConditionInput"},
},
},
},
"RuleActionInput": map[string]any{
"type": "object",
"required": []any{"actionType", "configurationJson"},
"properties": map[string]any{
"actionType": map[string]any{"type": "string", "example": "SyncGroupMembership", "description": "SyncGroupMembership | MoveToOu | EnsureGroupExists | AddToGroup"},
"configurationJson": map[string]any{"type": "string", "example": "{\"targetGroupDn\":\"CN=Sales,OU=Groups,DC=corp,DC=com\",\"syncMode\":\"FullSync\",\"createIfMissing\":true}"},
"isEnabled": boolean,
},
},
"RuleInput": map[string]any{
"type": "object",
"required": []any{"name", "adConnectionId", "objectType"},
"properties": map[string]any{
"name": str,
"description": str,
"isEnabled": boolean,
"adConnectionId": str,
"objectType": map[string]any{"type": "string", "enum": []any{"User", "Computer", "Group"}},
"baseDnOverride": str,
"searchScopeOverride": map[string]any{"type": "string", "enum": []any{"Base", "OneLevel", "Subtree"}},
"scheduleId": str,
"executionMode": map[string]any{"type": "string", "enum": []any{"Apply", "PreviewOnly"}},
"groupJoinOperator": map[string]any{"type": "string", "enum": []any{"AND", "OR"}},
"stopOnError": boolean,
"conditionGroups": map[string]any{
"type": "array",
"items": map[string]any{"$ref": "#/components/schemas/RuleConditionGroupInput"},
},
"actions": map[string]any{
"type": "array",
"items": map[string]any{"$ref": "#/components/schemas/RuleActionInput"},
},
},
},
"ConnectionInput": map[string]any{
"type": "object",
"required": []any{"name", "hosts", "rootDn"},
"properties": map[string]any{
"name": str, "description": str, "isEnabled": boolean,
"hosts": map[string]any{"type": "string", "description": "Comma-separated host(s)"},
"port": map[string]any{"type": "integer", "example": 636},
"useTls": boolean,
"useStartTls": boolean,
"allowInvalidCerts": boolean,
"rootDn": map[string]any{"type": "string", "example": "DC=corp,DC=com"},
"bindDn": str,
"credentialId": str,
"defaultSearchScope": map[string]any{"type": "string", "enum": []any{"Base", "OneLevel", "Subtree"}},
"timeoutSeconds": map[string]any{"type": "integer", "example": 30},
"pagingEnabled": boolean,
"pageSize": map[string]any{"type": "integer", "example": 1000},
},
},
"CredentialInput": map[string]any{
"type": "object",
"required": []any{"name", "credentialType"},
"properties": map[string]any{
"name": str, "description": str,
"credentialType": map[string]any{"type": "string", "example": "UsernamePassword"},
"username": map[string]any{"type": "string", "example": "CORP\\svc-orchestrad"},
"password": map[string]any{"type": "string", "description": "Write-only; never returned"},
"isEnabled": boolean,
},
},
"ScheduleInput": map[string]any{
"type": "object",
"required": []any{"name", "scheduleKind"},
"properties": map[string]any{
"name": str, "description": str, "isEnabled": boolean,
"scheduleKind": map[string]any{"type": "string", "enum": []any{"Easy", "Cron"}},
"easyIntervalValue": map[string]any{"type": "integer", "example": 15},
"easyIntervalUnit": map[string]any{"type": "string", "enum": []any{"Minutes", "Hours", "Days"}},
"cronExpression": map[string]any{"type": "string", "example": "0 0 * * * *", "description": "6-field (with seconds), UTC"},
"timezoneMode": str,
},
},
"APIKeyInput": map[string]any{
"type": "object",
"required": []any{"name"},
"properties": map[string]any{
"name": str,
"scope": map[string]any{"type": "string", "enum": []any{"read", "readwrite"}, "default": "readwrite"},
"userId": map[string]any{"type": "string", "description": "Defaults to the caller"},
"expiresAt": map[string]any{"type": "string", "format": "date-time"},
},
},
"UserInput": map[string]any{
"type": "object",
"required": []any{"username"},
"properties": map[string]any{
"username": str, "email": str, "displayName": str,
"password": map[string]any{"type": "string", "description": "Write-only"},
"isActive": boolean,
"roles": map[string]any{"type": "array", "items": str},
},
},
"SettingInput": map[string]any{
"type": "object",
"required": []any{"value"},
"properties": map[string]any{
"value": str, "valueType": str, "description": str, "isSensitive": boolean,
},
},
"TLSModeInput": map[string]any{
"type": "object",
"required": []any{"mode"},
"properties": map[string]any{
"mode": map[string]any{"type": "string", "enum": []any{"auto", "provided", "windows-store"}},
"windowsThumbprint": map[string]any{"type": "string", "description": "Required when mode is windows-store"},
},
},
"TLSCertificateInput": map[string]any{
"type": "object",
"required": []any{"certificatePem", "privateKeyPem"},
"properties": map[string]any{
"certificatePem": str, "privateKeyPem": str, "chainPem": str,
},
},
"QueryPreviewInput": map[string]any{
"type": "object",
"required": []any{"filter"},
"properties": map[string]any{
"baseDn": str,
"scope": map[string]any{"type": "string", "enum": []any{"base", "one", "subtree"}},
"filter": map[string]any{"type": "string", "example": "(objectClass=user)"},
"attributes": map[string]any{"type": "array", "items": str},
"limit": map[string]any{"type": "integer", "example": 100},
},
},
"ConfigImport": map[string]any{
"type": "object",
"description": "Either {payload:<config>, dryRun:bool} or a bare exported config object.",
"properties": map[string]any{
"payload": map[string]any{"type": "object"},
"dryRun": boolean,
},
},
}
}
// DocumentedAPIPaths returns the set of "METHOD /path" the spec covers, for the
// drift test that keeps documentation and routing in lock-step.
func DocumentedAPIPaths(router chi.Router) []string {
spec := BuildOpenAPISpec(router)
var out []string
for p, ops := range spec["paths"].(map[string]any) {
for m := range ops.(map[string]any) {
out = append(out, strings.ToUpper(m)+" "+p)
}
}
sort.Strings(out)
return out
}
const swaggerUIHTML = `<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>OrchestrAD API</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.17.14/swagger-ui.min.css"/>
</head>
<body>
<div id="swagger-ui"></div>
<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/docs/openapi.json" + window.location.search,
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis],
layout: "BaseLayout",
});
};
</script>
</body>
</html>`