63701dd086
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>
688 lines
25 KiB
Go
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>`
|