// 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 ` (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{ 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 `.", }, "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:, 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 = ` OrchestrAD API
`