From 46734e725ecec952e0912395e6f7a5ba327c32f0 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Mon, 19 Oct 2026 12:10:44 +0100 Subject: [PATCH 1/2] fix(api): document every routed path in openapi.yaml and guard against drift openapi.yaml had no entry for 49 registered routes (accounts, registrations, MFA, inventory, ladder, purchase retry/revoke/marketplace, plan accounts, trends, unsubscribe, federation IaC, /version, docs HEAD), so they were invisible to API consumers and exempt from the 403 permission guard. - Add TestOpenAPIDocumentsEveryRoute: replays the live router's first-match dispatch for every spec operation and fails when a route has no operation or an operation reaches no route. Stale exemptions also fail. - Backfill the missing operations and their schemas. - Correct documented operations that disagreed with their handlers: start/end (not start_date/end_date) on history and analytics, history provider/account_ids/limit (and no interval), analytics interval/provider, the breakdown account dimension, dashboard account_ids (no date range), recommendations account_ids and min_savings_* (no account_id), the refresh response and 409, config PUT status bodies, the provider/service config path, auth 409/429/503 codes and the application/yaml spec content type. - Make TestOpenAPI403OnPermissionGatedRoutes map each operation to the route the router actually dispatches it to, and ignore comment mentions of requirePermission, so documenting GET /api/purchases/revoke/{id} and GET /api/auth/me/permissions does not demand a 403 they never return. Co-Authored-By: Claude Opus 5.5 --- internal/api/openapi.yaml | 8297 ++++++++++++++----- internal/api/openapi_403_test.go | 53 +- internal/api/openapi_route_coverage_test.go | 215 + 3 files changed, 6458 insertions(+), 2107 deletions(-) create mode 100644 internal/api/openapi_route_coverage_test.go diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 5ad33ce0a..f2928134c 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -32,9 +32,16 @@ tags: - name: APIKeys - name: Users - name: Groups + - name: RIExchange - name: Health - name: Info - name: Docs + - name: Accounts + - name: Registrations + - name: Federation + - name: Inventory + - name: Ladder + - name: Notifications # --------------------------------------------------------------------------- # Paths @@ -53,9 +60,15 @@ paths: schema: type: string enum: [aws, azure, gcp] + - name: account_ids + in: query + description: > + Comma-separated cloud account UUIDs (at most 200). When present it + takes precedence over account_id. The result is always narrowed + to the caller's allowed accounts. + schema: + type: string - $ref: '#/components/parameters/AccountID' - - $ref: '#/components/parameters/StartDate' - - $ref: '#/components/parameters/EndDate' responses: '200': description: Dashboard summary @@ -106,6 +119,10 @@ paths: operationId: updateConfig tags: [Configuration] summary: Update global configuration + description: > + Partial update: keys present in the body replace the stored values + and absent keys are preserved. The response does not echo the + configuration; read it back with GET /api/config. parameters: - $ref: '#/components/parameters/CSRFToken' requestBody: @@ -116,11 +133,13 @@ paths: $ref: '#/components/schemas/GlobalConfig' responses: '200': - description: Updated configuration + description: Configuration saved content: application/json: schema: - $ref: '#/components/schemas/ConfigResponse' + $ref: '#/components/schemas/StatusResponse' + example: + status: updated '400': $ref: '#/components/responses/BadRequest' '401': @@ -128,35 +147,46 @@ paths: '403': $ref: '#/components/responses/Forbidden' - /api/config/service/{service}: + /api/config/service/{provider}/{service}: parameters: + - name: provider + in: path + required: true + schema: + type: string + enum: [aws, azure, gcp] - name: service in: path required: true + description: Service name within the provider, for example ec2 or rds. Letters, digits, hyphens and underscores only. schema: type: string - enum: [ec2, rds, elasticache, opensearch] get: operationId: getServiceConfig tags: [Configuration] summary: Get per-service configuration responses: '200': - description: Service configuration + description: > + Service configuration, or an empty object {} when none is stored + for this provider and service. content: application/json: schema: $ref: '#/components/schemas/ServiceConfig' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' put: operationId: updateServiceConfig tags: [Configuration] summary: Update per-service configuration + description: > + Provider and service are taken from the path. The response does not + echo the configuration; read it back with GET. parameters: - $ref: '#/components/parameters/CSRFToken' requestBody: @@ -167,11 +197,13 @@ paths: $ref: '#/components/schemas/ServiceConfig' responses: '200': - description: Updated service configuration + description: Service configuration saved content: application/json: schema: - $ref: '#/components/schemas/ServiceConfig' + $ref: '#/components/schemas/StatusResponse' + example: + status: updated '400': $ref: '#/components/responses/BadRequest' '401': @@ -223,7 +255,26 @@ paths: in: query schema: type: string - - $ref: '#/components/parameters/AccountID' + - name: account_ids + in: query + description: > + Comma-separated cloud account UUIDs (at most 200). Results are + always narrowed to the caller's allowed accounts. + schema: + type: string + - name: min_savings_usd + in: query + description: Minimum monthly savings in USD. Absent or 0 means no floor. + schema: + type: number + minimum: 0 + - name: min_savings_pct + in: query + description: Minimum effective savings percentage (0-100). Absent or 0 means no floor. + schema: + type: number + minimum: 0 + maximum: 100 responses: '200': description: Recommendations list @@ -231,6 +282,8 @@ paths: application/json: schema: $ref: '#/components/schemas/RecommendationsResponse' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -241,19 +294,30 @@ paths: operationId: refreshRecommendations tags: [Recommendations] summary: Trigger a fresh collection of recommendations from cloud providers + description: > + Starts a collection across every registered account; on Lambda it + runs asynchronously. Poll GET /api/recommendations/freshness until + last_collected_at passes started_at. Callers restricted to a subset + of accounts get 403. parameters: - $ref: '#/components/parameters/CSRFToken' responses: '200': - description: Collection result + description: Collection started content: application/json: schema: - $ref: '#/components/schemas/CollectResult' + $ref: '#/components/schemas/RefreshResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '409': + description: A collection is already in progress (started within the last 5 minutes). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /api/recommendations/freshness: get: @@ -278,6 +342,51 @@ paths: '403': $ref: '#/components/responses/Forbidden' + /api/recommendations/{id}/detail: + parameters: + - name: id + in: path + required: true + description: > + Recommendation ID as returned by GET /api/recommendations. It is a + pipe-separated identity (provider, account, service, region, + resource type, engine, term, payment, plus optional zone and + platform/tenancy segments) that can contain "/" and spaces, so + percent-encode it. + schema: + type: string + get: + operationId: getRecommendationDetail + tags: [Recommendations] + summary: Get the drill-down details of one recommendation + description: > + Backs the recommendation drawer. A recommendation hidden by an account + service override still resolves, with hidden_by naming the override + dimensions; a fully suppressed one answers 404. + responses: + '200': + description: Recommendation details + content: + application/json: + schema: + $ref: '#/components/schemas/RecommendationDetailResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: > + No such recommendation, it is fully suppressed, or it belongs to + an account outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + # ---- Purchase Plans ----------------------------------------------------- /api/plans: get: @@ -496,6 +605,96 @@ paths: schema: $ref: '#/components/schemas/ErrorWithCode' + /api/plans/{id}/accounts: + parameters: + - $ref: '#/components/parameters/ResourceID' + get: + operationId: listPlanAccounts + tags: [Plans] + summary: List the cloud accounts assigned to a plan + description: > + Requires view:plans. A caller with a restricted account scope sees + only the plan's accounts inside that scope, and gets 404 when none of + the plan's accounts is in scope. An unrestricted caller gets an empty + list for an unknown plan ID. + responses: + '200': + description: Accounts assigned to the plan, ordered by name + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CloudAccount' + '400': + description: The plan ID is not a valid UUID. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: No account of the plan is inside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + put: + operationId: setPlanAccounts + tags: [Plans] + summary: Replace the cloud accounts assigned to a plan + description: > + Requires update:plans. Replaces the plan's whole account list + atomically. Every account's provider must match one of the providers + of the plan's services. A caller with a restricted account scope must + have every current account of the plan and every requested account in + scope. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SetPlanAccountsRequest' + responses: + '200': + description: Accounts replaced. The body is an empty JSON object. + content: + application/json: + schema: + type: object + '400': + description: > + Invalid plan ID or request body, account_ids missing, empty or + holding a non-UUID entry, or one or more accounts have a provider + the plan's services do not use (every mismatch is listed). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: > + The plan or a requested account does not exist, or the plan or a + requested account is outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + $ref: '#/components/responses/EditorReconciliationRequired' + '500': + $ref: '#/components/responses/InternalError' + # ---- Purchase Actions --------------------------------------------------- /api/purchases/execute: post: @@ -779,6 +978,42 @@ paths: /api/purchases/revoke/{id}: parameters: - $ref: '#/components/parameters/ResourceID' + get: + operationId: getRevokeConfirmationForm + tags: [Purchases] + summary: Render the revocation confirmation form for the email link + description: > + The revocation email links here. Returns an HTML page with a form + that POSTs the token to this path; nothing is looked up or changed, so + mail prefetchers cannot trigger a revocation. The form submits a + plain browser form POST, which carries no Authorization or + X-CSRF-Token header. + security: [] + parameters: + - name: token + in: query + required: false + description: > + Approval token from the email link. Embedded unchanged (HTML + escaped) in the form's hidden token field; it is not validated + here. + schema: + type: string + responses: + '200': + description: HTML confirmation page + content: + text/html: + schema: + type: string + '400': + description: The execution ID is not a valid UUID. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' post: operationId: revokePurchaseViaEmail tags: [Purchases] @@ -787,10 +1022,8 @@ paths: Records a revocation request for a completed or partially completed purchase. The same path also answers GET with an unauthenticated HTML confirmation form (no state change, so mail prefetchers cannot - trigger a revocation); that form POSTs the token here. The GET - operation is not listed separately because the permission-gate - guard in openapi_403_test.go matches routes by prefix and would - mistake it for the GET /api/purchases/{id} details route. + trigger a revocation), documented as the GET operation on this path; + that form POSTs the token here. Two modes. Session mode: session bearer token plus X-CSRF-Token, with cancel-any or cancel-own and account scope, as for @@ -873,518 +1106,744 @@ paths: schema: $ref: '#/components/schemas/Error' - # ---- Planned Purchases -------------------------------------------------- - /api/purchases/planned: - get: - operationId: getPlannedPurchases - tags: [PlannedPurchases] - summary: List planned (scheduled) purchases + /api/purchases/approve/{id}/details: + parameters: + - $ref: '#/components/parameters/ResourceID' + post: + operationId: getApprovalDetails + tags: [Purchases] + summary: Read a pending purchase's details for the approval link + description: > + Read-only; POST only so the approval token travels in the body. A + signed-in session is always required. A session holding view:purchases + with the execution in its account scope gets the details without a + token. Otherwise the request needs both a valid approval token for + this execution and a session whose email is the contact email of an + account on the execution. Every final denial answers like a missing + execution (404). + security: + - bearerAuth: [] parameters: - - name: status - in: query - schema: - type: string - - name: plan_id + - $ref: '#/components/parameters/CSRFToken' + - name: token in: query + required: false + description: > + Approval token, accepted as a fallback. Prefer the request body so + the token stays out of access logs. schema: type: string + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + description: Approval token from the approval email link. + application/x-www-form-urlencoded: + schema: + type: object + properties: + token: + type: string + description: Approval token from the approval email link. responses: '200': - description: Planned purchases + description: Purchase execution details, as returned by GET /api/purchases/{id} content: application/json: schema: - $ref: '#/components/schemas/PlannedPurchasesResponse' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/purchases/planned/{id}/pause: - parameters: - - $ref: '#/components/parameters/ResourceID' - post: - operationId: pausePlannedPurchase - tags: [PlannedPurchases] - summary: Pause a planned purchase - description: Only pending executions can be paused. - parameters: - - $ref: '#/components/parameters/CSRFToken' - responses: - '200': - description: Paused + $ref: '#/components/schemas/PurchaseExecution' + '400': + description: The execution ID is not a valid UUID. content: application/json: schema: - $ref: '#/components/schemas/StatusResponse' - '409': - description: The execution is not pending and cannot be paused. + $ref: '#/components/schemas/Error' + '401': + description: > + The caller has no valid session. This is answered before the + execution is looked up, so an existing and a missing execution ID + get the same response. content: application/json: schema: $ref: '#/components/schemas/Error' - '401': - $ref: '#/components/responses/Unauthorized' '403': - $ref: '#/components/responses/Forbidden' + description: CSRF validation failed (checked before the execution is looked up). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '404': - $ref: '#/components/responses/NotFound' + description: > + Execution not found, or the caller may not read it (no + view:purchases or out of scope, and no valid token plus matching + contact email). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '410': + description: > + Token path only. The approval token matches but has expired. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' - /api/purchases/planned/{id}/resume: + /api/purchases/retry/{id}: parameters: - $ref: '#/components/parameters/ResourceID' post: - operationId: resumePlannedPurchase - tags: [PlannedPurchases] - summary: Resume a paused planned purchase + operationId: retryPurchase + tags: [Purchases] + summary: Retry a failed purchase execution + description: > + Creates a new pending execution from a failed execution's stored + recommendations and sends a fresh approval email; the failed row keeps + its status and links to the new one. Requires a bearer session (an + API key alone gets 401) holding retry-any, or retry-own for an + execution the caller created, with the execution in the caller's + account scope. The recommendations must also satisfy the Constraints + of the caller's execute permission on purchases. parameters: - $ref: '#/components/parameters/CSRFToken' + - name: force + in: query + required: false + description: > + "true" (case-insensitive) overrides the soft block applied once an + execution has been retried 5 times. It does not override any other + 409. + schema: + type: string responses: '200': - description: Resumed + description: Retry execution created content: application/json: schema: - $ref: '#/components/schemas/StatusResponse' + $ref: '#/components/schemas/RetryPurchaseResponse' + '400': + description: > + Invalid execution ID, or the stored recommendations fail validation + (negative cost or savings, totals above the allowed maximum, or a + zero total commitment). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': - $ref: '#/components/responses/Unauthorized' + description: No valid bearer session. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '403': - $ref: '#/components/responses/Forbidden' + description: > + CSRF validation failed, the session lacks retry-any or retry-own + (or retry-own on another user's execution), or the recommendations + exceed the Constraints of the session's execute permission. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '404': - $ref: '#/components/responses/NotFound' + description: Execution not found, or outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '409': - $ref: '#/components/responses/EditorReconciliationRequired' + description: > + The execution cannot be retried: its status is not failed, it was + already retried (retry_execution_id names the successor when + known; a concurrent retry also answers this way), it is a plan step + with no recommendations, it belongs to a settled ladder tranche, + a provider re-drive of it is unsafe (ops_hint plus + redrive_unsafe, permanent and not overridable by force), its + failure is operator-fixable (ops_hint plus failure_reason), or it + reached the retry threshold without force=true (retry_attempt_n + plus threshold). + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Error' + - type: object + properties: + retry_execution_id: + type: string + format: uuid + ops_hint: + type: string + redrive_unsafe: + type: boolean + failure_reason: + type: string + retry_attempt_n: + type: integer + threshold: + type: integer + '500': + $ref: '#/components/responses/InternalError' - /api/purchases/planned/{id}/run: + /api/purchases/{id}/revoke: parameters: - - $ref: '#/components/parameters/ResourceID' + - name: id + in: path + required: true + description: > + A purchase execution ID, or the purchase_id of a purchase history + row. For Azure the purchase_id is the reservation's ARM resource ID, + which contains "/" and is sent as-is: everything between + /api/purchases/ and /revoke is the ID. + schema: + type: string post: - operationId: runPlannedPurchase - tags: [PlannedPurchases] - summary: Immediately execute a planned purchase + operationId: revokePurchase + tags: [Purchases] + summary: Revoke a purchase within its free-cancel window + description: > + Requires a bearer session (an API key alone gets 401) holding + revoke-any or revoke-own on purchases, checked against the + permission's Constraints. When the ID is an execution, a still + scheduled execution is canceled before any provider call (status + "canceled"). Otherwise the purchase history row is revoked: only + Azure reservations are supported, within 7 days of purchase less a + one-hour safety margin, and the body must echo the quote from GET + /api/purchases/{id}/revoke/calculate. An already revoked row answers + "already_revoked"; a row whose Azure refund was issued but not yet + recorded answers code RECONCILE_PENDING and must not be retried. parameters: - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/RevokePurchaseRequest' responses: '200': - description: Execution initiated + description: Canceled, revoked, already revoked, or refund pending reconciliation content: application/json: schema: - $ref: '#/components/schemas/RunPurchaseResponse' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - - /api/purchases/planned/{id}: - parameters: - - $ref: '#/components/parameters/ResourceID' - delete: - operationId: deletePlannedPurchase - tags: [PlannedPurchases] - summary: Cancel and delete a planned purchase - parameters: - - $ref: '#/components/parameters/CSRFToken' - responses: - '200': - description: Cancelled + $ref: '#/components/schemas/RevokePurchaseResponse' + '400': + description: > + Invalid JSON body, expected_refund_amount or + expected_refund_currency missing for an Azure revoke, or Azure + rejected the refund calculation or return as a client error. content: application/json: schema: - $ref: '#/components/schemas/DeletePlannedPurchaseResponse' + $ref: '#/components/schemas/Error' '401': - $ref: '#/components/responses/Unauthorized' + description: No valid bearer session. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '403': - $ref: '#/components/responses/Forbidden' + description: > + CSRF validation failed, the session lacks revoke-any and + revoke-own, revoke-own on another user's scheduled execution, the + purchase is in an account the session cannot access or has no + account attribution, the request exceeds the permission's + Constraints, or the authentication service is not configured. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '404': - $ref: '#/components/responses/NotFound' + description: > + No execution or purchase history row has this ID, or the + execution is outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '410': + description: > + The ID is an execution that is no longer scheduled (the purchase + already ran or the execution was never scheduled), so it cannot be + canceled for free. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: > + The purchase cannot be revoked in-app: the provider is not Azure + (AWS and GCP have no direct cancel API), the Azure return window + has closed, the reservation IDs cannot be parsed from the + purchase_id, Azure returned no refund quote, or the confirmed + refund amount or currency diverges from Azure's current quote + (re-quote and confirm again). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' - # ---- Generic Purchase Details ------------------------------------------- - /api/purchases/{id}: + /api/purchases/{id}/revoke/calculate: parameters: - - $ref: '#/components/parameters/ResourceID' + - name: id + in: path + required: true + description: > + The purchase_id of a purchase history row. For Azure this is the + reservation's ARM resource ID, which contains "/" and is sent as-is. + schema: + type: string get: - operationId: getPurchaseDetails + operationId: calculateRevokeRefund tags: [Purchases] - summary: Get details of a specific purchase execution + summary: Quote the refund for revoking an Azure reservation + description: > + First step of the quote-then-confirm revoke flow; nothing is changed. + Runs Azure CalculateRefund and returns the amount and currency to send + back as expected_refund_amount and expected_refund_currency on POST + /api/purchases/{id}/revoke. Same session, permission and window rules + as that POST. responses: '200': - description: Purchase execution details + description: Refund quote content: application/json: schema: - $ref: '#/components/schemas/PurchaseExecution' + $ref: '#/components/schemas/RevokeRefundQuote' + '400': + description: Azure rejected the refund calculation as a client error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': - $ref: '#/components/responses/Unauthorized' + description: No valid bearer session. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '403': - $ref: '#/components/responses/Forbidden' + description: > + The session lacks revoke-any and revoke-own, the purchase is in an + account the session cannot access or has no account attribution, + the request exceeds the permission's Constraints, or the + authentication service is not configured. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '404': - $ref: '#/components/responses/NotFound' - - # ---- RI Exchange -------------------------------------------------------- - /api/ri-exchange/instances: - get: - operationId: listConvertibleRIs - tags: [RIExchange] - summary: List convertible Reserved Instances eligible for exchange - parameters: - - $ref: '#/components/parameters/AccountID' - responses: - '200': - description: Convertible RI list + description: No purchase history row has this purchase_id. content: application/json: schema: - type: object - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/ri-exchange/azure-instances: - get: - operationId: listExchangeableAzureRIs - tags: [RIExchange] - summary: List Azure Reserved Instances eligible for exchange - parameters: - - $ref: '#/components/parameters/AccountID' - responses: - '200': - description: Azure exchangeable RI list + $ref: '#/components/schemas/Error' + '422': + description: > + The provider is not Azure, the Azure return window has closed, the + reservation IDs cannot be parsed from the purchase_id, or Azure + returned no refund amount or currency. content: application/json: schema: - type: object - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' - /api/ri-exchange/azure-instances/compatible-offerings: + /api/purchases/{id}/marketplace-list: + parameters: + - name: id + in: path + required: true + description: The AWS Reserved Instance ID (purchase history purchase_id). + schema: + type: string + format: uuid post: - operationId: getAzureCompatibleOfferings - tags: [RIExchange] - summary: Price a proposed Azure RI exchange and list compatible offerings + operationId: createMarketplaceListing + tags: [Purchases] + summary: List a Standard Reserved Instance on the AWS RI Marketplace description: > - Requires `view:purchases` permission and `allowed_accounts` scope - over the cloud account registered for `subscription_id`. Runs Azure - CalculateExchange against the given source reservations and target - SKUs and returns the priced candidate offerings plus a cost preview, - without committing any financial transaction. - - Each target's billing scope is always derived from `subscription_id`. - A target may omit `billing_scope_id`; supplying one that names a - different subscription is a 400. + Requires a bearer session holding sell-any or sell-own on purchases, + checked against the permission's Constraints. The purchase's cloud + account must be in the caller's account scope; a purchase with no + cloud account needs sell-any and an unrestricted scope. Only Standard RIs with + at least one month remaining can be listed. Signs AWS calls as the + purchase's own cloud account. A retry after an ambiguous failure + reuses the stored client token and price schedule, so it does not + create a second listing. parameters: - $ref: '#/components/parameters/CSRFToken' requestBody: - required: true + required: false content: application/json: schema: - type: object - required: [subscription_id, sources, targets] - properties: - subscription_id: - type: string - sources: - type: array - minItems: 1 - maxItems: 50 - items: - $ref: '#/components/schemas/AzureExchangeSource' - targets: - type: array - minItems: 1 - maxItems: 50 - items: - $ref: '#/components/schemas/AzureExchangeTarget' + $ref: '#/components/schemas/MarketplaceListRequest' responses: '200': - description: Compatible offerings and cost preview + description: Listing created content: application/json: schema: - type: object + $ref: '#/components/schemas/MarketplaceListResponse' '400': - $ref: '#/components/responses/BadRequest' + description: > + Invalid ID or body, an invalid price_schedule (non-positive term + or price, a term beyond the remaining months, a price below the + floor, or no default price computable), the RI is not Standard, + the purchase's cloud account is missing, not AWS or cannot be + signed for, the account role could not be assumed, or AWS rejected + the request as a client error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': - $ref: '#/components/responses/Unauthorized' + description: No valid bearer session. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '403': - $ref: '#/components/responses/Forbidden' + description: > + CSRF validation failed, the session lacks sell-any and sell-own, + the purchase is outside the caller's accounts or has no account + attribution, or the request exceeds the permission's Constraints. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '404': - $ref: '#/components/responses/NotFound' + description: No purchase history row has this ID. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: A listing is already active or in progress for this RI. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '422': - $ref: '#/components/responses/UnprocessableEntity' + description: > + The purchase has no timestamp or an unsupported term, or less than + one month of its term remains. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + '502': + description: > + AWS failed, the role assumption failed transiently, AWS returned a + dead listing, or a created listing could not be saved (the message + says whether it was canceled or may still be active). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: AWS throttled the role assumption; retry after the Retry-After delay. + headers: + Retry-After: + description: Seconds to wait before retrying. + schema: + type: integer + content: + application/json: + schema: + $ref: '#/components/schemas/Error' - /api/ri-exchange/azure-instances/exchange: + /api/purchases/{id}/marketplace-cancel: + parameters: + - name: id + in: path + required: true + description: The AWS Reserved Instance ID (purchase history purchase_id). + schema: + type: string + format: uuid post: - operationId: executeAzureExchange - tags: [RIExchange] - summary: Execute an Azure RI exchange (irreversible) + operationId: cancelMarketplaceListing + tags: [Purchases] + summary: Cancel an active AWS RI Marketplace listing description: > - Requires `execute:ri-exchange` permission (deliberately separate from - `execute:purchases`, mirroring the AWS execute endpoint) plus - `allowed_accounts` scope over the cloud account registered for - `subscription_id`. Each target's billing scope is derived from - `subscription_id`, so the charge always lands on the account whose - permission constraints were checked; a `billing_scope_id` naming a - different subscription is a 400. The server - re-runs CalculateExchange itself against the given sources/targets - and only executes the fresh session that call returns -- a - client-supplied session is never trusted. Execution is refused when - the fresh quote carries policy errors, omits a net payable amount, - is denominated in a different currency than requested, or exceeds - `max_payment_due`. - Submits are deduplicated for 15 minutes on a fingerprint of - `subscription_id` plus the sources and targets with their quantities, - so a retry after a client timeout returns 409 rather than committing - the exchange a second time. The spend cap and currency are not part of - that fingerprint: raising the cap does not make it a different - purchase. See the 409 response: it does not assert that the earlier - submit succeeded, only that it claimed the window. + Same session, permission and account rules as marketplace-list. + Cancels the RI's active listing on AWS and records the new state. parameters: - $ref: '#/components/parameters/CSRFToken' - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [subscription_id, sources, targets, max_payment_due, currency] - properties: - subscription_id: - type: string - sources: - type: array - minItems: 1 - maxItems: 50 - items: - $ref: '#/components/schemas/AzureExchangeSource' - targets: - type: array - minItems: 1 - maxItems: 50 - items: - $ref: '#/components/schemas/AzureExchangeTarget' - max_payment_due: - type: string - description: Spend-cap guardrail (decimal string, e.g. "1000.00") - currency: - type: string - description: ISO 4217 currency code the cap is denominated in responses: '200': - description: Exchange executed + description: Listing canceled content: application/json: schema: type: object + required: [listing_id, listing_state] + properties: + listing_id: + type: string + listing_state: + type: string + description: Listing state reported by AWS after the cancel. '400': - $ref: '#/components/responses/BadRequest' + description: > + Invalid ID, the purchase's cloud account is missing, not AWS or + cannot be signed for, the account role could not be assumed, or + AWS rejected the cancel as a client error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': - $ref: '#/components/responses/Unauthorized' + description: No valid bearer session. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '403': - $ref: '#/components/responses/Forbidden' + description: > + CSRF validation failed, the session lacks sell-any and sell-own, + the purchase is outside the caller's accounts or has no account + attribution, or the request exceeds the permission's Constraints. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '404': - $ref: '#/components/responses/NotFound' + description: No purchase history row has this ID. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '409': - $ref: '#/components/responses/Conflict' - '422': - $ref: '#/components/responses/UnprocessableEntity' + description: The RI has no active listing. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: > + Server error, including a listing canceled on AWS whose new state + could not be saved. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '502': + description: AWS failed, or the role assumption failed transiently. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: AWS throttled the role assumption; retry after the Retry-After delay. + headers: + Retry-After: + description: Seconds to wait before retrying. + schema: + type: integer + content: + application/json: + schema: + $ref: '#/components/schemas/Error' - /api/ri-exchange/target-offerings: + # ---- Planned Purchases -------------------------------------------------- + /api/purchases/planned: get: - operationId: listTargetOfferings - tags: [RIExchange] - summary: List available RI offerings to exchange into + operationId: getPlannedPurchases + tags: [PlannedPurchases] + summary: List planned (scheduled) purchases parameters: - - $ref: '#/components/parameters/AccountID' + - name: status + in: query + schema: + type: string + - name: plan_id + in: query + schema: + type: string responses: '200': - description: Target offering list + description: Planned purchases content: application/json: schema: - type: object + $ref: '#/components/schemas/PlannedPurchasesResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - /api/ri-exchange/utilization: - get: - operationId: getRIUtilization - tags: [RIExchange] - summary: Get utilization metrics for Reserved Instances + /api/purchases/planned/{id}/pause: + parameters: + - $ref: '#/components/parameters/ResourceID' + post: + operationId: pausePlannedPurchase + tags: [PlannedPurchases] + summary: Pause a planned purchase + description: Only pending executions can be paused. + parameters: + - $ref: '#/components/parameters/CSRFToken' responses: '200': - description: RI utilization data + description: Paused content: application/json: schema: - type: object + $ref: '#/components/schemas/StatusResponse' + '409': + description: The execution is not pending and cannot be paused. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /api/ri-exchange/reshape-recommendations: - get: - operationId: getReshapeRecommendations - tags: [RIExchange] - summary: Get reshape recommendations for convertible RIs + /api/purchases/planned/{id}/resume: + parameters: + - $ref: '#/components/parameters/ResourceID' + post: + operationId: resumePlannedPurchase + tags: [PlannedPurchases] + summary: Resume a paused planned purchase + parameters: + - $ref: '#/components/parameters/CSRFToken' responses: '200': - description: Reshape recommendation list + description: Resumed content: application/json: schema: - type: object + $ref: '#/components/schemas/StatusResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/EditorReconciliationRequired' - /api/ri-exchange/quote: + /api/purchases/planned/{id}/run: + parameters: + - $ref: '#/components/parameters/ResourceID' post: - operationId: getExchangeQuote - tags: [RIExchange] - summary: Get a quote for an RI exchange before executing - description: > - Requires `view:purchases` permission. Returns the exchange valuation - without committing any financial transaction. + operationId: runPlannedPurchase + tags: [PlannedPurchases] + summary: Immediately execute a planned purchase parameters: - $ref: '#/components/parameters/CSRFToken' - requestBody: - required: true - content: - application/json: - schema: - type: object responses: '200': - description: Exchange quote + description: Execution initiated content: application/json: schema: - type: object - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/RunPurchaseResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' - /api/ri-exchange/execute: - post: - operationId: executeRIExchange - tags: [RIExchange] - summary: Execute an RI exchange (irreversible) - description: > - Requires `execute:ri-exchange` permission. This is intentionally a - separate, narrower permission from `execute:purchases` because RI - exchanges submitted to AWS cannot be rolled back. Non-admin users must - be explicitly granted `execute:ri-exchange` via a custom group; there - is no default user-role grant. - Submits are deduplicated for 15 minutes on a fingerprint of the - deployment's cloud account and region plus the source RIs and targets - with their counts, so a retry after a client timeout returns 409 rather - than committing the exchange a second time. `max_payment_due_usd` is - not part of that fingerprint: raising the cap does not make it a - different purchase. See the 409 response: it does not assert that the - earlier submit succeeded, only that it claimed the window. + /api/purchases/planned/{id}: + parameters: + - $ref: '#/components/parameters/ResourceID' + delete: + operationId: deletePlannedPurchase + tags: [PlannedPurchases] + summary: Cancel and delete a planned purchase parameters: - $ref: '#/components/parameters/CSRFToken' - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [ri_ids, targets, max_payment_due_usd] - properties: - ri_ids: - type: array - items: - type: string - targets: - type: array - items: - type: object - max_payment_due_usd: - type: string - description: Spend-cap guardrail (decimal string, e.g. "1000.00") responses: '200': - description: Exchange submitted + description: Cancelled content: application/json: schema: - type: object - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/DeletePlannedPurchaseResponse' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - '409': - $ref: '#/components/responses/Conflict' + '404': + $ref: '#/components/responses/NotFound' - /api/ri-exchange/config: + # ---- Generic Purchase Details ------------------------------------------- + /api/purchases/{id}: + parameters: + - $ref: '#/components/parameters/ResourceID' get: - operationId: getRIExchangeConfig - tags: [RIExchange] - summary: Get RI exchange configuration - description: Requires `view:config` permission. + operationId: getPurchaseDetails + tags: [Purchases] + summary: Get details of a specific purchase execution responses: '200': - description: RI exchange configuration + description: Purchase execution details content: application/json: schema: - type: object + $ref: '#/components/schemas/PurchaseExecution' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - put: - operationId: updateRIExchangeConfig + '404': + $ref: '#/components/responses/NotFound' + + # ---- RI Exchange -------------------------------------------------------- + /api/ri-exchange/instances: + get: + operationId: listConvertibleRIs tags: [RIExchange] - summary: Update RI exchange configuration - description: Requires `update:config` permission. + summary: List convertible Reserved Instances eligible for exchange parameters: - - $ref: '#/components/parameters/CSRFToken' - requestBody: - required: true - content: - application/json: - schema: - type: object + - $ref: '#/components/parameters/AccountID' responses: '200': - description: Updated configuration + description: Convertible RI list content: application/json: schema: type: object - '400': - $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - /api/ri-exchange/history: + /api/ri-exchange/azure-instances: get: - operationId: getRIExchangeHistory + operationId: listExchangeableAzureRIs tags: [RIExchange] - summary: Get RI exchange history + summary: List Azure Reserved Instances eligible for exchange + parameters: + - $ref: '#/components/parameters/AccountID' responses: '200': - description: RI exchange history list + description: Azure exchangeable RI list content: application/json: schema: @@ -1394,1322 +1853,4919 @@ paths: '403': $ref: '#/components/responses/Forbidden' - /api/ri-exchange/approve/{id}: - parameters: - - $ref: '#/components/parameters/ResourceID' + /api/ri-exchange/azure-instances/compatible-offerings: post: - operationId: approveRIExchange + operationId: getAzureCompatibleOfferings tags: [RIExchange] - summary: Approve a pending RI exchange + summary: Price a proposed Azure RI exchange and list compatible offerings description: > - Two modes. Session mode: session bearer token plus X-CSRF-Token; the - session must hold approve-any, or approve-own for an exchange it - created, within the deployment account scope. Token mode (email - link): the approval token in the query string, no session needed - unless 4-eyes mode is on, in which case a token request with no - session fails with 500, including for an exchange with no recorded - creator (every scheduler- or ladder-created exchange); such an - exchange has no requester to differ from, so under 4-eyes any - signed-in approver may approve it. A session that holds approve permission wins - over a token in the same request (the token is ignored); a session - that lacks the permission, or holds only approve-own for an exchange - it did not create, falls through to token mode when a token is - present; a CSRF rejection never falls through. The token is read - from the query string only. This route - never returns 401: a request with no token and no usable session - fails CSRF validation with 403. A missing record (404) and a wrong - token (403) are distinguishable for unauthenticated callers. - security: - - bearerAuth: [] - - {} + Requires `view:purchases` permission and `allowed_accounts` scope + over the cloud account registered for `subscription_id`. Runs Azure + CalculateExchange against the given source reservations and target + SKUs and returns the priced candidate offerings plus a cost preview, + without committing any financial transaction. + + Each target's billing scope is always derived from `subscription_id`. + A target may omit `billing_scope_id`; supplying one that names a + different subscription is a 400. parameters: - - name: token - in: query - required: false - schema: - type: string - - name: X-CSRF-Token - in: header - required: false - description: CSRF token from the login response. Required in session mode. - schema: - type: string + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [subscription_id, sources, targets] + properties: + subscription_id: + type: string + sources: + type: array + minItems: 1 + maxItems: 50 + items: + $ref: '#/components/schemas/AzureExchangeSource' + targets: + type: array + minItems: 1 + maxItems: 50 + items: + $ref: '#/components/schemas/AzureExchangeTarget' responses: '200': - description: Exchange approved and executed (the body carries a status such as completed or failed) + description: Compatible offerings and cost preview content: application/json: schema: type: object '400': $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '403': - description: > - CSRF validation failed (including a request with neither a token - nor a valid session), the session lacks approve permission, the - token is invalid, the record does not support approval, or, when a - session exists, 4-eyes mode rejects the approver (for example the - creator approving their own exchange). - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' - '409': - description: > - The exchange is no longer pending (already processed, expired or - canceled by a newer analysis run). - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '429': - $ref: '#/components/responses/RateLimited' - '500': - description: > - Server error, or 4-eyes mode is on and a token request carried no - resolvable session. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + '422': + $ref: '#/components/responses/UnprocessableEntity' - /api/ri-exchange/reject/{id}: - parameters: - - $ref: '#/components/parameters/ResourceID' + /api/ri-exchange/azure-instances/exchange: post: - operationId: rejectRIExchange + operationId: executeAzureExchange tags: [RIExchange] - summary: Reject a pending RI exchange (token-based, no session required) + summary: Execute an Azure RI exchange (irreversible) description: > - Authorized by the rejection token in the query string alone. A session - is ignored and no CSRF check runs. A missing token is 400; a missing - record (404) and a wrong token (403) are distinguishable for - unauthenticated callers. - security: [] + Requires `execute:ri-exchange` permission (deliberately separate from + `execute:purchases`, mirroring the AWS execute endpoint) plus + `allowed_accounts` scope over the cloud account registered for + `subscription_id`. Each target's billing scope is derived from + `subscription_id`, so the charge always lands on the account whose + permission constraints were checked; a `billing_scope_id` naming a + different subscription is a 400. The server + re-runs CalculateExchange itself against the given sources/targets + and only executes the fresh session that call returns -- a + client-supplied session is never trusted. Execution is refused when + the fresh quote carries policy errors, omits a net payable amount, + is denominated in a different currency than requested, or exceeds + `max_payment_due`. + Submits are deduplicated for 15 minutes on a fingerprint of + `subscription_id` plus the sources and targets with their quantities, + so a retry after a client timeout returns 409 rather than committing + the exchange a second time. The spend cap and currency are not part of + that fingerprint: raising the cap does not make it a different + purchase. See the 409 response: it does not assert that the earlier + submit succeeded, only that it claimed the window. parameters: - - name: token - in: query - required: false - schema: - type: string + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [subscription_id, sources, targets, max_payment_due, currency] + properties: + subscription_id: + type: string + sources: + type: array + minItems: 1 + maxItems: 50 + items: + $ref: '#/components/schemas/AzureExchangeSource' + targets: + type: array + minItems: 1 + maxItems: 50 + items: + $ref: '#/components/schemas/AzureExchangeTarget' + max_payment_due: + type: string + description: Spend-cap guardrail (decimal string, e.g. "1000.00") + currency: + type: string + description: ISO 4217 currency code the cap is denominated in responses: '200': - description: Exchange rejected + description: Exchange executed content: application/json: schema: - $ref: '#/components/schemas/StatusResponse' + type: object '400': $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' '403': - description: The rejection token is invalid, or the record does not support rejection. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': - description: The exchange is no longer pending (already processed, expired or canceled). + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/UnprocessableEntity' + + /api/ri-exchange/target-offerings: + get: + operationId: listTargetOfferings + tags: [RIExchange] + summary: List available RI offerings to exchange into + parameters: + - $ref: '#/components/parameters/AccountID' + responses: + '200': + description: Target offering list content: application/json: schema: - $ref: '#/components/schemas/Error' - '429': - $ref: '#/components/responses/RateLimited' - '500': - $ref: '#/components/responses/InternalError' - - # ---- History ------------------------------------------------------------ - /api/history: - get: - operationId: getHistory - tags: [History] - summary: Get purchase history - parameters: - - $ref: '#/components/parameters/AccountID' - - $ref: '#/components/parameters/StartDate' - - $ref: '#/components/parameters/EndDate' - - name: interval - in: query - schema: - type: string - enum: [hourly, daily, weekly, monthly] - responses: - '200': - description: Purchase history with summary - content: - application/json: - schema: - $ref: '#/components/schemas/HistoryResponse' + type: object '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - /api/history/analytics: + /api/ri-exchange/utilization: get: - operationId: getHistoryAnalytics - tags: [History] - summary: Get time-series analytics for purchase history - parameters: - - $ref: '#/components/parameters/AccountID' - - $ref: '#/components/parameters/StartDate' - - $ref: '#/components/parameters/EndDate' + operationId: getRIUtilization + tags: [RIExchange] + summary: Get utilization metrics for Reserved Instances responses: '200': - description: Analytics data + description: RI utilization data content: application/json: schema: - $ref: '#/components/schemas/AnalyticsResponse' + type: object '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - /api/history/breakdown: + /api/ri-exchange/reshape-recommendations: get: - operationId: getHistoryBreakdown - tags: [History] - summary: Get savings breakdown by dimension - parameters: - - $ref: '#/components/parameters/AccountID' - - $ref: '#/components/parameters/StartDate' - - $ref: '#/components/parameters/EndDate' - - name: dimension - in: query - schema: - type: string - enum: [service, provider, region] - default: service + operationId: getReshapeRecommendations + tags: [RIExchange] + summary: Get reshape recommendations for convertible RIs responses: '200': - description: Breakdown data + description: Reshape recommendation list content: application/json: schema: - $ref: '#/components/schemas/BreakdownResponse' + type: object '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - # ---- Analytics ---------------------------------------------------------- - /api/analytics/collect: + /api/ri-exchange/quote: post: - operationId: triggerAnalyticsCollection - tags: [Analytics] - summary: Trigger manual analytics data collection (admin only) + operationId: getExchangeQuote + tags: [RIExchange] + summary: Get a quote for an RI exchange before executing + description: > + Requires `view:purchases` permission. Returns the exchange valuation + without committing any financial transaction. parameters: - $ref: '#/components/parameters/CSRFToken' - responses: - '200': - description: Collection completed - content: - application/json: - schema: - $ref: '#/components/schemas/StatusMessageResponse' - '401': - $ref: '#/components/responses/Unauthorized' - - # ---- Auth --------------------------------------------------------------- - /api/auth/login: - post: - operationId: login - tags: [Auth] - summary: Authenticate and obtain a session token - security: [] requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/LoginRequest' + type: object responses: '200': - description: Login successful + description: Exchange quote content: application/json: schema: - $ref: '#/components/schemas/LoginResponse' + type: object '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '429': - $ref: '#/components/responses/RateLimited' + '403': + $ref: '#/components/responses/Forbidden' - /api/auth/logout: + /api/ri-exchange/execute: post: - operationId: logout - tags: [Auth] - summary: Invalidate current session + operationId: executeRIExchange + tags: [RIExchange] + summary: Execute an RI exchange (irreversible) + description: > + Requires `execute:ri-exchange` permission. This is intentionally a + separate, narrower permission from `execute:purchases` because RI + exchanges submitted to AWS cannot be rolled back. Non-admin users must + be explicitly granted `execute:ri-exchange` via a custom group; there + is no default user-role grant. + Submits are deduplicated for 15 minutes on a fingerprint of the + deployment's cloud account and region plus the source RIs and targets + with their counts, so a retry after a client timeout returns 409 rather + than committing the exchange a second time. `max_payment_due_usd` is + not part of that fingerprint: raising the cap does not make it a + different purchase. See the 409 response: it does not assert that the + earlier submit succeeded, only that it claimed the window. parameters: - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [ri_ids, targets, max_payment_due_usd] + properties: + ri_ids: + type: array + items: + type: string + targets: + type: array + items: + type: object + max_payment_due_usd: + type: string + description: Spend-cap guardrail (decimal string, e.g. "1000.00") responses: '200': - description: Logged out + description: Exchange submitted content: application/json: schema: - $ref: '#/components/schemas/StatusResponse' + type: object + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' - /api/auth/me: + /api/ri-exchange/config: get: - operationId: getCurrentUser - tags: [Auth] - summary: Get the currently authenticated user + operationId: getRIExchangeConfig + tags: [RIExchange] + summary: Get RI exchange configuration + description: Requires `view:config` permission. responses: '200': - description: Current user + description: RI exchange configuration content: application/json: schema: - $ref: '#/components/schemas/CurrentUserResponse' + type: object '401': $ref: '#/components/responses/Unauthorized' - - /api/auth/check-admin: - get: - operationId: checkAdminExists - tags: [Auth] - summary: Check whether an admin account has been set up - security: [] - responses: - '200': - description: Admin existence status - content: - application/json: - schema: - $ref: '#/components/schemas/AdminExistsResponse' - '429': - $ref: '#/components/responses/RateLimited' - - /api/auth/setup-admin: - post: - operationId: setupAdmin - tags: [Auth] - summary: Create the initial admin account (first-run only) - security: [] + '403': + $ref: '#/components/responses/Forbidden' + put: + operationId: updateRIExchangeConfig + tags: [RIExchange] + summary: Update RI exchange configuration + description: Requires `update:config` permission. + parameters: + - $ref: '#/components/parameters/CSRFToken' requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/SetupAdminRequest' + type: object responses: '200': - description: Admin created and logged in + description: Updated configuration content: application/json: schema: - $ref: '#/components/schemas/LoginResponse' + type: object '400': $ref: '#/components/responses/BadRequest' - '429': - $ref: '#/components/responses/RateLimited' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' - /api/auth/forgot-password: - post: - operationId: forgotPassword - tags: [Auth] - summary: Request a password reset email - security: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PasswordResetRequest' + /api/ri-exchange/history: + get: + operationId: getRIExchangeHistory + tags: [RIExchange] + summary: Get RI exchange history responses: '200': - description: > - Always returns success to prevent email enumeration. + description: RI exchange history list content: application/json: schema: - $ref: '#/components/schemas/StatusResponse' - '429': - $ref: '#/components/responses/RateLimited' + type: object + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' - /api/auth/reset-password: + /api/ri-exchange/approve/{id}: + parameters: + - $ref: '#/components/parameters/ResourceID' post: - operationId: resetPassword - tags: [Auth] - summary: Confirm a password reset with a token - security: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PasswordResetConfirm' - responses: - '200': - description: Password reset - content: - application/json: - schema: - $ref: '#/components/schemas/StatusResponse' - '400': - $ref: '#/components/responses/BadRequest' - - /api/auth/profile: - put: - operationId: updateProfile - tags: [Auth] - summary: Update the authenticated user's profile + operationId: approveRIExchange + tags: [RIExchange] + summary: Approve a pending RI exchange + description: > + Two modes. Session mode: session bearer token plus X-CSRF-Token; the + session must hold approve-any, or approve-own for an exchange it + created, within the deployment account scope. Token mode (email + link): the approval token in the query string, no session needed + unless 4-eyes mode is on, in which case a token request with no + session fails with 500, including for an exchange with no recorded + creator (every scheduler- or ladder-created exchange); such an + exchange has no requester to differ from, so under 4-eyes any + signed-in approver may approve it. A session that holds approve permission wins + over a token in the same request (the token is ignored); a session + that lacks the permission, or holds only approve-own for an exchange + it did not create, falls through to token mode when a token is + present; a CSRF rejection never falls through. The token is read + from the query string only. This route + never returns 401: a request with no token and no usable session + fails CSRF validation with 403. A missing record (404) and a wrong + token (403) are distinguishable for unauthenticated callers. + security: + - bearerAuth: [] + - {} parameters: - - $ref: '#/components/parameters/CSRFToken' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ProfileUpdateRequest' + - name: token + in: query + required: false + schema: + type: string + - name: X-CSRF-Token + in: header + required: false + description: CSRF token from the login response. Required in session mode. + schema: + type: string responses: '200': - description: Profile updated + description: Exchange approved and executed (the body carries a status such as completed or failed) content: application/json: schema: - $ref: '#/components/schemas/StatusResponse' + type: object '400': $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' + '403': + description: > + CSRF validation failed (including a request with neither a token + nor a valid session), the session lacks approve permission, the + token is invalid, the record does not support approval, or, when a + session exists, 4-eyes mode rejects the approver (for example the + creator approving their own exchange). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: > + The exchange is no longer pending (already processed, expired or + canceled by a newer analysis run). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' + '500': + description: > + Server error, or 4-eyes mode is on and a token request carried no + resolvable session. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' - /api/auth/change-password: + /api/ri-exchange/reject/{id}: + parameters: + - $ref: '#/components/parameters/ResourceID' post: - operationId: changePassword - tags: [Auth] - summary: Change the authenticated user's password + operationId: rejectRIExchange + tags: [RIExchange] + summary: Reject a pending RI exchange (token-based, no session required) + description: > + Authorized by the rejection token in the query string alone. A session + is ignored and no CSRF check runs. A missing token is 400; a missing + record (404) and a wrong token (403) are distinguishable for + unauthenticated callers. + security: [] parameters: - - $ref: '#/components/parameters/CSRFToken' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ChangePasswordRequest' + - name: token + in: query + required: false + schema: + type: string responses: '200': - description: Password changed + description: Exchange rejected content: application/json: schema: $ref: '#/components/schemas/StatusResponse' '400': $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' + '403': + description: The rejection token is invalid, or the record does not support rejection. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: The exchange is no longer pending (already processed, expired or canceled). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' - # ---- API Keys ----------------------------------------------------------- - /api/api-keys: + # ---- History ------------------------------------------------------------ + /api/history: get: - operationId: listAPIKeys - tags: [APIKeys] - summary: List API keys for the current user + operationId: getHistory + tags: [History] + summary: Get purchase history + parameters: + - $ref: '#/components/parameters/HistoryProvider' + - name: account_ids + in: query + description: > + Comma-separated cloud account UUIDs (at most 200). Combined with + account_id (the union of both is matched). + schema: + type: string + - $ref: '#/components/parameters/AccountID' + - name: start + in: query + description: > + First day to include (YYYY-MM-DD, UTC). When only one of start + and end is sent, the other defaults to 366 days away from it. + schema: + type: string + format: date + - name: end + in: query + description: Last day to include (YYYY-MM-DD, UTC, inclusive). The range may span at most 366 days. + schema: + type: string + format: date + - name: limit + in: query + description: > + Maximum number of completed purchase rows. Values below 1 fall + back to the default; values above 1000 are capped at 1000. + schema: + type: integer + default: 100 responses: '200': - description: API keys + description: Purchase history with summary content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/APIKeyInfo' + $ref: '#/components/schemas/HistoryResponse' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' - post: - operationId: createAPIKey - tags: [APIKeys] - summary: Create a new API key + + /api/history/analytics: + get: + operationId: getHistoryAnalytics + tags: [History] + summary: Get time-series analytics for purchase history parameters: - - $ref: '#/components/parameters/CSRFToken' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateAPIKeyRequest' + - $ref: '#/components/parameters/AnalyticsAccountID' + - $ref: '#/components/parameters/StartDate' + - $ref: '#/components/parameters/EndDate' + - name: interval + in: query + description: Bucket size of the returned data points. + schema: + type: string + enum: [hourly, daily, weekly, monthly] + default: hourly + - $ref: '#/components/parameters/HistoryProvider' responses: '200': - description: Newly created API key (full key shown only once) + description: Analytics data content: application/json: schema: - $ref: '#/components/schemas/CreateAPIKeyResponse' + $ref: '#/components/schemas/AnalyticsResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + description: account_id is outside the caller's allowed accounts. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/AnalyticsUnavailable' - /api/api-keys/usage-stats: + /api/history/breakdown: get: - operationId: getAPIKeysUsageStats - tags: [APIKeys] - summary: Section-level usage summary for the caller's API keys - description: | - Aggregates per-key counters into a single summary card: number of - usable keys, total requests (current window + lifetime), and the - top-3 keys by current-window request count. Scoped to the calling - user's own keys. Counts from windows that have already closed are - reported as zero, not as current activity. + operationId: getHistoryBreakdown + tags: [History] + summary: Get savings breakdown by dimension + parameters: + - $ref: '#/components/parameters/AnalyticsAccountID' + - $ref: '#/components/parameters/StartDate' + - $ref: '#/components/parameters/EndDate' + - name: dimension + in: query + schema: + type: string + enum: [service, provider, region, account] + default: service responses: '200': - description: API keys usage stats + description: Breakdown data content: application/json: schema: - $ref: '#/components/schemas/APIKeysUsageStats' + $ref: '#/components/schemas/BreakdownResponse' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + description: account_id is outside the caller's allowed accounts. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + $ref: '#/components/responses/AnalyticsUnavailable' - /api/api-keys/{id}/revoke: - parameters: - - $ref: '#/components/parameters/ResourceID' + # ---- Analytics ---------------------------------------------------------- + /api/analytics/collect: post: - operationId: revokeAPIKey - tags: [APIKeys] - summary: Revoke (deactivate) an API key + operationId: triggerAnalyticsCollection + tags: [Analytics] + summary: Trigger manual analytics data collection (admin only) parameters: - $ref: '#/components/parameters/CSRFToken' responses: '200': - description: Revoked + description: Collection completed content: application/json: schema: - $ref: '#/components/schemas/StatusResponse' + $ref: '#/components/schemas/StatusMessageResponse' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - /api/api-keys/{id}: - parameters: - - $ref: '#/components/parameters/ResourceID' - delete: - operationId: deleteAPIKey - tags: [APIKeys] - summary: Permanently delete an API key + /api/analytics/trends: + get: + operationId: getAnalyticsTrends + tags: [Analytics] + summary: Get savings-snapshot trends + description: > + Requires `view:purchases` permission. Returns monthly savings totals + plus by-provider and by-service breakdowns from the savings snapshot + store. The monthly series covers `months` calendar months counted back + from the current month; the breakdowns cover the requested range. parameters: - - $ref: '#/components/parameters/CSRFToken' + - name: account_id + in: query + description: > + Cloud account ID or external account ID. Required for sessions + restricted to specific accounts; omit for all accessible accounts. + schema: + type: string + - name: start + in: query + description: RFC 3339 or YYYY-MM-DD. Defaults to 7 days before `end`. + schema: + type: string + - name: end + in: query + description: > + RFC 3339 or YYYY-MM-DD (a date covers the whole day). Defaults to + now. The range may span at most 366 days. + schema: + type: string + - name: provider + in: query + description: Filters `by_service` only. Not validated; an unknown value yields no rows. + schema: + type: string responses: '200': - description: Deleted + description: Savings trends content: application/json: schema: - $ref: '#/components/schemas/StatusResponse' + $ref: '#/components/schemas/AnalyticsTrendsResponse' + '400': + description: > + Invalid date, start after end, range over 366 days, or a + restricted session omitted `account_id`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': - $ref: '#/components/responses/NotFound' - - # ---- User Management ---------------------------------------------------- - /api/users: - get: - operationId: listUsers - tags: [Users] - summary: List all users (admin only) - responses: - '200': - description: Users list + description: The account is outside the session's allowed accounts. content: application/json: schema: - type: object - properties: - users: - type: array - items: - $ref: '#/components/schemas/User' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/schemas/Error' + '503': + description: The savings snapshot store is not configured. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + # ---- Auth --------------------------------------------------------------- + /api/auth/login: post: - operationId: createUser - tags: [Users] - summary: Create a new user (admin only) - parameters: - - $ref: '#/components/parameters/CSRFToken' + operationId: login + tags: [Auth] + summary: Authenticate and obtain a session token + security: [] requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/CreateUserRequest' + $ref: '#/components/schemas/LoginRequest' responses: '200': - description: Created user + description: Login successful content: application/json: schema: - $ref: '#/components/schemas/User' + $ref: '#/components/schemas/LoginResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/RateLimiterUnavailable' - /api/users/{id}: - parameters: - - $ref: '#/components/parameters/ResourceID' - get: - operationId: getUser - tags: [Users] - summary: Get a user by ID (admin only) - responses: - '200': - description: User - content: - application/json: - schema: - $ref: '#/components/schemas/User' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - put: - operationId: updateUser - tags: [Users] - summary: Update a user (admin only) + /api/auth/logout: + post: + operationId: logout + tags: [Auth] + summary: Invalidate current session parameters: - $ref: '#/components/parameters/CSRFToken' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateUserRequest' responses: '200': - description: Updated user + description: Logged out content: application/json: schema: - $ref: '#/components/schemas/User' - '400': - $ref: '#/components/responses/BadRequest' + $ref: '#/components/schemas/StatusResponse' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - delete: - operationId: deleteUser - tags: [Users] - summary: Delete a user (admin only) - parameters: - - $ref: '#/components/parameters/CSRFToken' + + /api/auth/me: + get: + operationId: getCurrentUser + tags: [Auth] + summary: Get the currently authenticated user responses: '200': - description: Deleted + description: Current user content: application/json: schema: - $ref: '#/components/schemas/StatusResponse' + $ref: '#/components/schemas/CurrentUserResponse' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - # ---- Group Management --------------------------------------------------- - /api/groups: + /api/auth/check-admin: get: - operationId: listGroups - tags: [Groups] - summary: List all groups (admin only) + operationId: checkAdminExists + tags: [Auth] + summary: Check whether an admin account has been set up + security: [] responses: '200': - description: Groups list + description: Admin existence status content: application/json: schema: - type: object - properties: - groups: - type: array - items: - $ref: '#/components/schemas/Group' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' + $ref: '#/components/schemas/AdminExistsResponse' + '429': + $ref: '#/components/responses/RateLimited' + + /api/auth/setup-admin: post: - operationId: createGroup - tags: [Groups] - summary: Create a new group (admin only) - parameters: - - $ref: '#/components/parameters/CSRFToken' + operationId: setupAdmin + tags: [Auth] + summary: Create the initial admin account (first-run only) + security: [] requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/CreateGroupRequest' + $ref: '#/components/schemas/SetupAdminRequest' responses: '200': - description: Created group + description: Admin created and logged in content: application/json: schema: - $ref: '#/components/schemas/Group' + $ref: '#/components/schemas/LoginResponse' '400': $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - - /api/groups/{id}: - parameters: - - $ref: '#/components/parameters/ResourceID' - get: - operationId: getGroup - tags: [Groups] - summary: Get a group by ID (admin only) - responses: - '200': - description: Group + '409': + description: An admin account already exists. content: application/json: schema: - $ref: '#/components/schemas/Group' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - put: - operationId: updateGroup - tags: [Groups] - summary: Update a group (admin only) - parameters: - - $ref: '#/components/parameters/CSRFToken' + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/RateLimiterUnavailable' + + /api/auth/forgot-password: + post: + operationId: forgotPassword + tags: [Auth] + summary: Request a password reset email + security: [] requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/UpdateGroupRequest' + $ref: '#/components/schemas/PasswordResetRequest' responses: '200': - description: Updated group + description: > + Always returns success to prevent email enumeration. content: application/json: schema: - $ref: '#/components/schemas/Group' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - delete: - operationId: deleteGroup - tags: [Groups] - summary: Delete a group (admin only) + $ref: '#/components/schemas/StatusResponse' + '429': + $ref: '#/components/responses/RateLimited' + + /api/auth/reset-password: + post: + operationId: resetPassword + tags: [Auth] + summary: Confirm a password reset with a token + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordResetConfirm' + responses: + '200': + description: Password reset + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '400': + $ref: '#/components/responses/BadRequest' + '409': + description: The account changed concurrently while the password was being reset; retry. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' + '503': + $ref: '#/components/responses/RateLimiterUnavailable' + + /api/auth/profile: + put: + operationId: updateProfile + tags: [Auth] + summary: Update the authenticated user's profile parameters: - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProfileUpdateRequest' responses: '200': - description: Deleted + description: Profile updated content: application/json: schema: $ref: '#/components/schemas/StatusResponse' + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' - '403': - $ref: '#/components/responses/Forbidden' - '404': - $ref: '#/components/responses/NotFound' - # ---- Health ------------------------------------------------------------- - /health: + /api/auth/change-password: + post: + operationId: changePassword + tags: [Auth] + summary: Change the authenticated user's password + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChangePasswordRequest' + responses: + '200': + description: Password changed + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/auth/me/permissions: get: - operationId: healthCheckRoot - tags: [Health] - summary: Health check (root path) - security: [] + operationId: getCurrentUserPermissions + tags: [Auth] + summary: Get the caller's effective permissions + description: > + For a session, the union of the user's group permissions. For a user + API key, the key's permissions intersected with its owner's. For the + admin API key, `{admin, *}`. `is_admin` is true when the set holds + `{admin, *}`. responses: '200': - description: Health status + description: Effective permissions content: application/json: schema: - $ref: '#/components/schemas/HealthResponse' + $ref: '#/components/schemas/UserPermissionsResponse' + '401': + $ref: '#/components/responses/Unauthorized' - /ready: + /api/auth/reset-password/status: get: - operationId: readinessCheckRoot - tags: [Health] - summary: Readiness check (root path) - description: >- - Traffic-admission contract: answers 200 with the same payload as - /health once this replica has completed initialization, 503 while any - store check is pending or failed or migrations have not run yet. A - failed migration run is reported in the body but still answers 200. - /health is the liveness counterpart and always answers 200. + operationId: getResetPasswordStatus + tags: [Auth] + summary: Check a password reset or invite token without consuming it + description: > + Returns 200 for every token state. `used` also covers tokens that were + never issued and accounts that are deactivated. Shares the + reset-password rate limit (10 requests per 15 minutes per IP). security: [] + parameters: + - name: token + in: query + required: true + description: Reset or invite token from the email link + schema: + type: string responses: '200': - description: Replica is ready to serve traffic + description: Token state content: application/json: schema: - $ref: '#/components/schemas/HealthResponse' + $ref: '#/components/schemas/ResetPasswordStatusResponse' + '400': + description: The token parameter is missing. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' '503': - description: Replica is still initializing or a store check failed + description: The rate limiter is unavailable (fails closed). content: application/json: schema: - $ref: '#/components/schemas/HealthResponse' + $ref: '#/components/schemas/Error' - /api/health: - get: - operationId: healthCheck - tags: [Health] - summary: Health check - security: [] + /api/auth/mfa/setup: + post: + operationId: setupMFA + tags: [Auth] + summary: Start MFA enrollment + description: > + Generates a pending TOTP secret (valid for 5 minutes) and its + otpauth provisioning URI. Repeating the call replaces the pending + secret. Requires a bearer-token session; API keys get 401. Rate + limited to 10 MFA requests per 15 minutes per IP and per user. A + missing user, failed lookup or locked account answers 401. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MFASetupRequest' responses: '200': - description: Health status + description: Pending secret created content: application/json: schema: - $ref: '#/components/schemas/HealthResponse' - - # ---- Public Info -------------------------------------------------------- - /api/info: - get: - operationId: getPublicInfo - tags: [Info] - summary: Public instance information (version, admin status — no auth required) - security: [] - responses: - '200': - description: Public info + $ref: '#/components/schemas/MFASetupResponse' + '400': + description: Invalid body, password encoding or password. content: application/json: schema: - $ref: '#/components/schemas/PublicInfoResponse' + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: CSRF validation failed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: > + MFA is already enabled (disable it first), or the account changed + concurrently. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' + '503': + description: The rate limiter is unavailable (fails closed). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' - /api/info/deployment: - get: - operationId: getDeploymentInfo - tags: [Info] - summary: Deployment-scoped identifiers (AuthUser required) + /api/auth/mfa/enable: + post: + operationId: enableMFA + tags: [Auth] + summary: Finish MFA enrollment description: > - Returns the Secrets Manager console URL and the deployment AWS account ID. - Requires at least a valid user session. Not accessible to unauthenticated - callers (closes #633). - security: - - bearerAuth: [] - - apiKeyAuth: [] + Verifies a TOTP code against the pending secret, enables MFA and + returns 10 recovery codes, shown only once. Requires a bearer-token + session; API keys get 401. Rate limited to 10 MFA requests per 15 + minutes per IP and per user. A wrong code counts toward account + lockout; a locked account answers 401. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MFAEnableRequest' responses: '200': - description: Deployment info + description: MFA enabled content: application/json: schema: - $ref: '#/components/schemas/DeploymentInfoResponse' + $ref: '#/components/schemas/MFARecoveryCodesResponse' + '400': + description: > + Invalid body, invalid code, no enrollment in progress, or the + enrollment expired. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' '401': $ref: '#/components/responses/Unauthorized' - - # ---- Docs -------------------------------------------------------------- - /api/docs: - get: - operationId: swaggerUI - tags: [Docs] - summary: Swagger UI - security: [] - responses: - '200': - description: HTML page + '403': + description: CSRF validation failed. content: - text/html: + application/json: schema: - type: string - - /api/docs/openapi.yaml: - get: - operationId: getOpenAPISpec - tags: [Docs] - summary: Raw OpenAPI specification - security: [] - responses: - '200': - description: OpenAPI YAML + $ref: '#/components/schemas/Error' + '409': + description: MFA is already enabled, or the account changed concurrently. content: - application/x-yaml: + application/json: schema: - type: string + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' + '503': + description: The rate limiter is unavailable (fails closed). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' - /docs: - get: - operationId: swaggerUIAlias - tags: [Docs] - summary: Swagger UI (alias) - security: [] + /api/auth/mfa/disable: + post: + operationId: disableMFA + tags: [Auth] + summary: Disable MFA + description: > + Requires the current password and, while MFA is on, a TOTP code or an + unused recovery code. With MFA already off, the correct password alone + succeeds and clears any pending enrollment. Requires a bearer-token + session; API keys get 401. Rate limited to 10 MFA requests per 15 + minutes per IP and per user. A wrong code counts toward account + lockout; a locked account answers 401. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MFADisableRequest' responses: '200': - description: HTML page + description: MFA disabled content: - text/html: + application/json: schema: - type: string + $ref: '#/components/schemas/StatusResponse' + '400': + description: > + Invalid body or password encoding, wrong password, missing code, + or invalid code. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: CSRF validation failed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: The account changed concurrently. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' + '503': + description: The rate limiter is unavailable (fails closed). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' -# --------------------------------------------------------------------------- -# Components -# --------------------------------------------------------------------------- -components: - # ---- Security Schemes --------------------------------------------------- - securitySchemes: - bearerAuth: - type: http - scheme: bearer - description: Session token returned by POST /api/auth/login - apiKeyAuth: - type: apiKey - in: header - name: X-API-Key - description: API key created via POST /api/api-keys + /api/auth/mfa/regenerate-recovery-codes: + post: + operationId: regenerateMFARecoveryCodes + tags: [Auth] + summary: Replace MFA recovery codes + description: > + Requires a current TOTP code (a recovery code is not accepted). + Replaces all stored recovery codes and returns 10 new ones, shown only + once. Requires a bearer-token session; API keys get 401. Rate limited + to 10 MFA requests per 15 minutes per IP and per user. A wrong code + counts toward account lockout; a locked account answers 401. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MFARegenerateRequest' + responses: + '200': + description: New recovery codes + content: + application/json: + schema: + $ref: '#/components/schemas/MFARecoveryCodesResponse' + '400': + description: Invalid body, invalid code, or MFA is not enabled. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: CSRF validation failed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: The account changed concurrently. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' + '503': + description: The rate limiter is unavailable (fails closed). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + # ---- API Keys ----------------------------------------------------------- + /api/api-keys: + get: + operationId: listAPIKeys + tags: [APIKeys] + summary: List API keys for the current user + responses: + '200': + description: API keys + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/APIKeyInfo' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + operationId: createAPIKey + tags: [APIKeys] + summary: Create a new API key + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAPIKeyRequest' + responses: + '200': + description: Newly created API key (full key shown only once) + content: + application/json: + schema: + $ref: '#/components/schemas/CreateAPIKeyResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/api-keys/usage-stats: + get: + operationId: getAPIKeysUsageStats + tags: [APIKeys] + summary: Section-level usage summary for the caller's API keys + description: | + Aggregates per-key counters into a single summary card: number of + usable keys, total requests (current window + lifetime), and the + top-3 keys by current-window request count. Scoped to the calling + user's own keys. Counts from windows that have already closed are + reported as zero, not as current activity. + responses: + '200': + description: API keys usage stats + content: + application/json: + schema: + $ref: '#/components/schemas/APIKeysUsageStats' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/api-keys/{id}/revoke: + parameters: + - $ref: '#/components/parameters/ResourceID' + post: + operationId: revokeAPIKey + tags: [APIKeys] + summary: Revoke (deactivate) an API key + parameters: + - $ref: '#/components/parameters/CSRFToken' + responses: + '200': + description: Revoked + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /api/api-keys/{id}: + parameters: + - $ref: '#/components/parameters/ResourceID' + delete: + operationId: deleteAPIKey + tags: [APIKeys] + summary: Permanently delete an API key + parameters: + - $ref: '#/components/parameters/CSRFToken' + responses: + '200': + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + # ---- User Management ---------------------------------------------------- + /api/users: + get: + operationId: listUsers + tags: [Users] + summary: List all users (admin only) + responses: + '200': + description: Users list + content: + application/json: + schema: + type: object + properties: + users: + type: array + items: + $ref: '#/components/schemas/User' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + operationId: createUser + tags: [Users] + summary: Create a new user (admin only) + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + '200': + description: Created user + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/users/{id}: + parameters: + - $ref: '#/components/parameters/ResourceID' + get: + operationId: getUser + tags: [Users] + summary: Get a user by ID (admin only) + responses: + '200': + description: User + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + put: + operationId: updateUser + tags: [Users] + summary: Update a user (admin only) + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRequest' + responses: + '200': + description: Updated user + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + delete: + operationId: deleteUser + tags: [Users] + summary: Delete a user (admin only) + parameters: + - $ref: '#/components/parameters/CSRFToken' + responses: + '200': + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + # ---- Group Management --------------------------------------------------- + /api/groups: + get: + operationId: listGroups + tags: [Groups] + summary: List all groups (admin only) + responses: + '200': + description: Groups list + content: + application/json: + schema: + type: object + properties: + groups: + type: array + items: + $ref: '#/components/schemas/Group' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + operationId: createGroup + tags: [Groups] + summary: Create a new group (admin only) + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateGroupRequest' + responses: + '200': + description: Created group + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/groups/{id}: + parameters: + - $ref: '#/components/parameters/ResourceID' + get: + operationId: getGroup + tags: [Groups] + summary: Get a group by ID (admin only) + responses: + '200': + description: Group + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + put: + operationId: updateGroup + tags: [Groups] + summary: Update a group (admin only) + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateGroupRequest' + responses: + '200': + description: Updated group + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + delete: + operationId: deleteGroup + tags: [Groups] + summary: Delete a group (admin only) + parameters: + - $ref: '#/components/parameters/CSRFToken' + responses: + '200': + description: Deleted + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + # ---- Cloud Accounts --------------------------------------------------- + /api/accounts/discover-org: + post: + operationId: discoverOrgAccounts + tags: [Accounts] + summary: Import the member accounts of an AWS organization (admin only) + description: > + Uses the stored credentials of an AWS org-root account to list the + organization's member accounts and creates a cloud account for each + one not already registered (matched by external_id). New accounts are + created disabled, with an empty aws_auth_mode and aws_bastion_id set + to the org root, so an operator must configure and enable each one. + The caller needs unrestricted account access. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverOrgRequest' + responses: + '200': + description: Discovery summary + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverOrgResult' + '400': + description: > + Invalid JSON body, account_id is not a UUID, the account is not an + AWS account, or it is not configured as an org root. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: > + CSRF validation failed, the caller is not an admin, or the caller's + account access is restricted. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Org-root account not found, or outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: > + The org root's credentials could not be resolved, the AWS + Organizations call failed, or a discovered account could not be saved. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/accounts/self: + post: + operationId: createSelfAccount + tags: [Accounts] + summary: Register CUDly's own host cloud account (admin only) + description: > + Creates an enabled cloud account for the AWS account, Azure + subscription or GCP project CUDly itself runs in, using the host's + ambient credentials. No request body is read. + parameters: + - $ref: '#/components/parameters/CSRFToken' + responses: + '200': + description: Created account (is_self is true) + content: + application/json: + schema: + $ref: '#/components/schemas/CloudAccount' + '400': + description: > + CUDly cannot detect its own host identity, or the detected values + fail account validation. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + description: The host account is already registered. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/accounts/list: + get: + operationId: listAccountSummaries + tags: [Accounts] + summary: List accounts with minimal fields + description: > + Returns only id, name, external_id and provider for each account in + the caller's account scope, for account pickers and filters. Requires + view:recommendations rather than view:accounts. Sorted by name. + parameters: + - name: provider + in: query + description: Only return accounts of this provider (aws, azure or gcp). + schema: + type: string + - name: enabled + in: query + description: Only return enabled (true) or disabled (false) accounts. Other values are ignored. + schema: + type: boolean + - name: search + in: query + description: Case-insensitive substring match on name or external_id. + schema: + type: string + responses: + '200': + description: Account summaries + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AccountSummary' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/accounts: + get: + operationId: listAccounts + tags: [Accounts] + summary: List cloud accounts + description: > + Returns the full account objects in the caller's account scope, + sorted by name. Requires view:accounts. + parameters: + - name: provider + in: query + description: Only return accounts of this provider (aws, azure or gcp). + schema: + type: string + - name: enabled + in: query + description: Only return enabled (true) or disabled (false) accounts. Other values are ignored. + schema: + type: boolean + - name: search + in: query + description: Case-insensitive substring match on name or external_id. + schema: + type: string + responses: + '200': + description: Cloud accounts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CloudAccount' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + operationId: createAccount + tags: [Accounts] + summary: Create a cloud account (admin only) + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CloudAccountRequest' + responses: + '200': + description: Created account + content: + application/json: + schema: + $ref: '#/components/schemas/CloudAccount' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + description: An account with this provider and external_id already exists. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/accounts/{id}/credentials: + parameters: + - $ref: '#/components/parameters/ResourceID' + post: + operationId: saveAccountCredentials + tags: [Accounts] + summary: Store credentials for a cloud account (admin only) + description: > + Stores (or replaces) one credential of the given type for the account. + The payload keys are validated per credential_type and unknown keys + are rejected. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AccountCredentialsRequest' + responses: + '200': + description: Stored. The body is an empty JSON object. + content: + application/json: + schema: + type: object + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Account not found, or outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /api/accounts/{id}/test: + parameters: + - $ref: '#/components/parameters/ResourceID' + post: + operationId: testAccountCredentials + tags: [Accounts] + summary: Test a cloud account's credentials (admin only) + description: > + Checks that the account's credentials work. AWS accounts are resolved + the same way purchases resolve them and then probed with + sts:GetCallerIdentity, which must return the account's external_id. + Azure managed identity and GCP application default credentials + report ok without a live check. Azure and GCP workload identity + federation accounts perform a live token exchange when the deployment + has an OIDC signer; other modes only check that the expected + credential is stored. A failed check is reported as ok=false with + status 200. + parameters: + - $ref: '#/components/parameters/CSRFToken' + responses: + '200': + description: Test result + content: + application/json: + schema: + $ref: '#/components/schemas/AccountTestResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Account not found, or outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/accounts/{id}/service-overrides: + parameters: + - $ref: '#/components/parameters/ResourceID' + get: + operationId: listAccountServiceOverrides + tags: [Accounts] + summary: List an account's per-service overrides + responses: + '200': + description: Service overrides (empty array when none) + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AccountServiceOverride' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Account not found, or outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/accounts/{id}/service-overrides/{provider}/{service}: + parameters: + - $ref: '#/components/parameters/ResourceID' + - name: provider + in: path + required: true + schema: + type: string + enum: [aws, azure, gcp] + - name: service + in: path + required: true + description: Service name, for example ec2. + schema: + type: string + pattern: '^[a-z0-9][a-z0-9-]{0,63}$' + put: + operationId: saveAccountServiceOverride + tags: [Accounts] + summary: Create or replace an account's override for one service (admin only) + description: > + Upserts the override for this account, provider and service. The + override is replaced as a whole: fields omitted from the body are + cleared and inherit the global service configuration. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AccountServiceOverrideRequest' + responses: + '200': + description: Saved override + content: + application/json: + schema: + $ref: '#/components/schemas/AccountServiceOverride' + '400': + description: > + Invalid path (account ID, provider or service name), invalid + request body, or an AWS term and payment combination the service + does not offer. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Account not found, or outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + operationId: deleteAccountServiceOverride + tags: [Accounts] + summary: Delete an account's override for one service (admin only) + description: > + Removes the override so the service inherits the global + configuration again. Succeeds when no override exists. + parameters: + - $ref: '#/components/parameters/CSRFToken' + responses: + '200': + description: Deleted. The body is an empty JSON object. + content: + application/json: + schema: + type: object + '400': + description: Invalid path (account ID, provider or service name). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Account not found, or outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/accounts/{id}: + parameters: + - $ref: '#/components/parameters/ResourceID' + get: + operationId: getAccount + tags: [Accounts] + summary: Get a cloud account by ID + responses: + '200': + description: Cloud account + content: + application/json: + schema: + $ref: '#/components/schemas/CloudAccount' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Account not found, or outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + put: + operationId: updateAccount + tags: [Accounts] + summary: Replace a cloud account (admin only) + description: > + Replaces the account's configuration with the request body; fields + omitted from the body are cleared, and enabled defaults to false. + The provider is validated but not changed. Stored credentials are + not affected. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CloudAccountRequest' + responses: + '200': + description: > + Updated account, built from the request (credentials_configured + is always false here; read the account again for the stored value). + content: + application/json: + schema: + $ref: '#/components/schemas/CloudAccount' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Account not found, or outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: Another account with this provider and external_id already exists. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + operationId: deleteAccount + tags: [Accounts] + summary: Delete a cloud account (admin only) + description: > + Deletes the account with its credentials and service overrides. + Purchase executions in a terminal status are kept as history and + detached from the account, and enabled plans that target only this + account are disabled. The delete is refused while any other purchase + execution still uses the account. + parameters: + - $ref: '#/components/parameters/CSRFToken' + responses: + '200': + description: Deleted. The body is an empty JSON object. + content: + application/json: + schema: + type: object + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Account not found, or outside the caller's account scope. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: > + Purchase executions still need the account. reason is + pending_executions when pending or notified purchases must be + canceled first, or active_executions when approved, running, + scheduled or paused purchases must finish or be revoked or deleted. + content: + application/json: + schema: + $ref: '#/components/schemas/AccountDeleteConflict' + + # ---- Account Registration --------------------------------------------- + /api/register: + post: + operationId: submitRegistration + tags: [Registrations] + summary: Submit an account self-registration request + description: > + Public endpoint called by the federation IaC templates (or by hand) to + ask an admin to onboard a cloud account. Creates a pending + registration and notifies the admins. Rate limited per client IP. + Keep the returned reference_token to poll GET /api/register/{token}. + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrationRequest' + responses: + '200': + description: Registration recorded as pending + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrationSubmitResponse' + '400': + $ref: '#/components/responses/BadRequest' + '409': + description: A pending registration already exists for this provider and external_id. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + + /api/register/{token}: + parameters: + - name: token + in: path + required: true + description: Reference token returned by POST /api/register (64 hex characters). + schema: + type: string + get: + operationId: getRegistrationStatus + tags: [Registrations] + summary: Get the status of a registration by its reference token + description: > + Public, limited view of a registration: only its status, creation + time and, when rejected, the rejection reason. + security: [] + responses: + '200': + description: Registration status + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrationStatusResponse' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalError' + + /api/registrations: + get: + operationId: listRegistrations + tags: [Registrations] + summary: List account registrations (admin only) + description: Returns matching registrations, newest first. Filters combine with AND. + parameters: + - name: status + in: query + description: Exact status match. + schema: + type: string + enum: [pending, approved, rejected] + - name: provider + in: query + description: Exact provider match. + schema: + type: string + enum: [aws, azure, gcp] + - name: search + in: query + description: > + Case-insensitive substring match against account_name, + external_id and contact_email. % and _ are matched literally. + schema: + type: string + responses: + '200': + description: Registrations (empty array when none match) + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AccountRegistration' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + + /api/registrations/{id}/approve: + parameters: + - $ref: '#/components/parameters/ResourceID' + post: + operationId: approveRegistration + tags: [Registrations] + summary: Approve a pending registration and create the cloud account (admin only) + description: > + The body has the same format as the account-create request, + typically pre-filled from the registration and optionally edited. + The registration moves to approved, a cloud account is created from + the body, and any credential submitted with the registration is + stored for the new account and wiped from the registration. The + account is enabled when the registration carried a credential or its + auth mode needs no stored secret; the body's enabled value is + ignored. The contact email is notified of the decision. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CloudAccountRequest' + responses: + '200': + description: The created cloud account + content: + application/json: + schema: + $ref: '#/components/schemas/CloudAccount' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: > + The registration is not pending, another request processed it + concurrently, or an account with this provider and external_id + already exists. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /api/registrations/{id}/reject: + parameters: + - $ref: '#/components/parameters/ResourceID' + post: + operationId: rejectRegistration + tags: [Registrations] + summary: Reject a pending registration (admin only) + description: > + Moves the registration to rejected and notifies its contact email. + The body is optional. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/RejectRegistrationRequest' + responses: + '200': + description: The rejected registration (reference token omitted) + content: + application/json: + schema: + $ref: '#/components/schemas/RejectRegistrationResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: > + The registration is not pending, or another request processed it + concurrently. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /api/registrations/{id}: + parameters: + - $ref: '#/components/parameters/ResourceID' + get: + operationId: getRegistration + tags: [Registrations] + summary: Get a registration by ID (admin only) + responses: + '200': + description: Registration + content: + application/json: + schema: + $ref: '#/components/schemas/AccountRegistration' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalError' + delete: + operationId: deleteRegistration + tags: [Registrations] + summary: Delete a registration (admin only) + description: Deletes the registration record in any status. A cloud account created by approving it is kept. + parameters: + - $ref: '#/components/parameters/CSRFToken' + responses: + '200': + description: Deleted (status is "deleted") + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalError' + + # ---- Federation ------------------------------------------------------- + /api/federation/iac: + get: + operationId: getFederationIaC + tags: [Federation] + summary: Generate federation IaC for onboarding a target account + description: > + Renders generic infrastructure-as-code that sets up trust from a + target cloud account to this CUDly deployment and self-registers the + account. Requires view:accounts. The file is returned inside a JSON + envelope: format=cli yields a shell script as plain text; every other + format yields a zip archive, base64-encoded. format=cfn requires + target=aws; format=bicep and format=arm require target=azure. When + target equals source, CUDly must be deployed on that cloud. + parameters: + - name: target + in: query + required: true + description: Cloud of the account being onboarded. + schema: + type: string + enum: [aws, azure, gcp] + - name: source + in: query + required: true + description: Cloud whose identity CUDly uses to reach the target. + schema: + type: string + enum: [aws, azure, gcp] + - name: format + in: query + required: true + description: > + cli (shell script), bundle (Terraform module and tfvars, plus a + CloudFormation fallback for target=aws), cfn (CloudFormation), + bicep or arm (Azure templates). + schema: + type: string + enum: [cli, bundle, cfn, bicep, arm] + responses: + '200': + description: Generated file + content: + application/json: + schema: + $ref: '#/components/schemas/FederationIaCResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + description: > + Server error, including a deployment whose own cloud identity + (AWS account ID, Azure subscription or tenant, GCP project) cannot + be resolved. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + # ---- Inventory -------------------------------------------------------- + /api/inventory/commitments: + get: + operationId: listInventoryCommitments + tags: [Inventory] + summary: List active commitments + description: > + Requires `view:purchases` permission. Returns unexpired commitments + from purchase history within the session's allowed accounts, soonest + expiring first. An `account_id` outside the allowed accounts yields an + empty list. + parameters: + - name: account_id + in: query + description: Cloud account ID or external account ID + schema: + type: string + - name: provider + in: query + description: Exact, case-sensitive provider match. Not validated. + schema: + type: string + responses: + '200': + description: Active commitments + content: + application/json: + schema: + $ref: '#/components/schemas/InventoryCommitmentsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /api/inventory/coverage: + get: + operationId: getCoverageBreakdown + tags: [Inventory] + summary: Get commitment coverage by provider and service + description: > + Requires `view:purchases` permission. Covered monthly spend comes from + active commitments (recurring cost plus amortized upfront); the + on-demand gap comes from recommendation savings and is treated as zero + if recommendations cannot be read. Always returns one section per + provider (aws, azure, gcp). + parameters: + - name: account_id + in: query + description: Cloud account ID or external account ID + schema: + type: string + - name: provider + in: query + description: > + Exact provider match. Not validated. Other providers are still + listed, with no services. + schema: + type: string + responses: + '200': + description: Coverage breakdown + content: + application/json: + schema: + $ref: '#/components/schemas/CoverageBreakdownResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + # ---- Commitment Laddering --------------------------------------------- + /api/ladder/configs: + get: + operationId: listLadderConfigs + tags: [Ladder] + summary: List ladder configurations + description: > + Requires `view:config` permission. Only configurations for the + session's allowed accounts are returned. + responses: + '200': + description: Ladder configurations, newest first + content: + application/json: + schema: + $ref: '#/components/schemas/LadderConfigsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + put: + operationId: upsertLadderConfig + tags: [Ladder] + summary: Create or update a ladder configuration + description: > + Requires `update:config` permission within the target account's scope. + Upserts on (cloud_account_id, provider). Omitted numeric fields take + their defaults; unknown fields are rejected. A write that arms the + config (enabled with mode `auto_approve`) or raises or clears an armed + config's cap also requires `execute:purchases`, and the admin API key + is refused. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LadderConfigUpsertRequest' + responses: + '200': + description: Stored configuration + content: + application/json: + schema: + $ref: '#/components/schemas/LadderConfig' + '400': + description: > + Invalid body, missing or invalid field, or the provider does not + match the cloud account's provider. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: The cloud account does not exist or is outside the session's allowed accounts. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/ladder/runs: + get: + operationId: listLadderRuns + tags: [Ladder] + summary: List ladder runs for an account + description: > + Requires `view:config` permission within the account's scope. Pages of + up to 100 runs, oldest first. AWS accounts only. + parameters: + - name: account_id + in: query + required: true + description: Cloud account ID + schema: + type: string + format: uuid + - name: provider + in: query + required: true + description: Must match the account's provider; only aws is served. + schema: + type: string + - name: after_created_at + in: query + description: > + Cursor from `next_cursor.created_at` (RFC 3339). Send together + with `after_id`. + schema: + type: string + format: date-time + - name: after_id + in: query + description: Cursor from `next_cursor.id`. Send together with `after_created_at`. + schema: + type: string + format: uuid + responses: + '200': + description: Page of runs + content: + application/json: + schema: + $ref: '#/components/schemas/LadderTimelineRunPage' + '400': + description: Missing account_id or provider, or an invalid cursor. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: > + The account does not exist, is outside the session's allowed + accounts, or has a different provider. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '501': + description: Ladder planning is available only for AWS accounts. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: Ladder timeline storage is unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/ladder/tranches: + get: + operationId: listLadderTranches + tags: [Ladder] + summary: List ladder tranches for an account + description: > + Requires `view:config` permission within the account's scope. Pages of + up to 100 tranches, oldest first; totals cover every tranche of the + account, not only the page. AWS accounts only. + parameters: + - name: account_id + in: query + required: true + description: Cloud account ID + schema: + type: string + format: uuid + - name: provider + in: query + required: true + description: Must match the account's provider; only aws is served. + schema: + type: string + - name: after_created_at + in: query + description: > + Cursor from `next_cursor.created_at` (RFC 3339). Send together + with `after_id`. + schema: + type: string + format: date-time + - name: after_id + in: query + description: Cursor from `next_cursor.id`. Send together with `after_created_at`. + schema: + type: string + format: uuid + responses: + '200': + description: Page of tranches + content: + application/json: + schema: + $ref: '#/components/schemas/LadderTimelinePage' + '400': + description: Missing account_id or provider, or an invalid cursor. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: > + The account does not exist, is outside the session's allowed + accounts, or has a different provider. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: > + A tranche has an unreadable amount basis or commitment rate, or + another server error occurred. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '501': + description: Ladder planning is available only for AWS accounts. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: Ladder timeline storage is unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/ladder/tranches/{id}: + parameters: + - $ref: '#/components/parameters/ResourceID' + patch: + operationId: amendLadderTranche + tags: [Ladder] + summary: Amend a scheduled ladder tranche + description: > + Requires `update:config` permission within the tranche's account + scope. Changes the amount and date of a future, scheduled tranche of a + planned run that has no execution yet, guarded by `expected_revision`. + AWS accounts only. A malformed ID answers 404. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LadderTrancheAmendment' + responses: + '200': + description: Amended tranche + content: + application/json: + schema: + $ref: '#/components/schemas/LadderTrancheAmendmentResult' + '400': + description: > + Invalid body, a past scheduled_date, or the new amount would + exceed the original run total, the current per-run cap or the + config commitment ceiling. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: > + The tranche changed (revision mismatch) or is no longer editable: + its run is not planned, it is not scheduled, it has an execution, + or its date has passed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '501': + description: Ladder planning is available only for AWS accounts. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: > + The ladder lock is busy (retry after the Retry-After delay), or + ladder amendment storage is unavailable. + headers: + Retry-After: + description: Seconds to wait before retrying (lock busy only) + schema: + type: integer + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/ladder/tranches/{id}/resolve: + parameters: + - $ref: '#/components/parameters/ResourceID' + post: + operationId: resolveLadderTranche + tags: [Ladder] + summary: Resolve a fired ladder tranche + description: > + Requires `execute:purchases` permission within the tranche's account + scope; the admin API key is refused. Moves a fired tranche to failed + when no execution of its retry chain can still run or has bought + anything. If an execution's outcome is unknown, the request must set + `acknowledge_unknown_outcome` and name the `cloud_check` performed. An + identical repeat returns 200 with `replayed: true`. Outcome + `completed` is not implemented yet (501). AWS accounts only. A + malformed ID answers 404. + parameters: + - $ref: '#/components/parameters/CSRFToken' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LadderTrancheResolveRequest' + responses: + '200': + description: Tranche resolved as failed + content: + application/json: + schema: + $ref: '#/components/schemas/LadderTrancheResolveResponse' + '400': + description: > + Invalid body (over 4096 bytes, unknown field, bad length or + characters, acknowledge_unknown_outcome without cloud_check or the + reverse), or a cloud_check that names another account or service, + is dated in the future, or predates the last execution change. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + description: > + The tranche cannot be resolved: it is not fired (or was resolved + with a different reason), an execution may still run or may have + purchased, an unknown outcome was not acknowledged, or the chain + changed too recently. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '501': + description: > + Outcome `completed` is not available yet, or the account is not an + AWS account. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: Ladder resolve storage is unavailable. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + # ---- Notifications ---------------------------------------------------- + /api/notifications/unsubscribe: + parameters: + - name: token + in: query + required: true + description: Signed token for the email and scope, from the email's unsubscribe link. + schema: + type: string + - name: email + in: query + required: true + description: Recipient address to mute. + schema: + type: string + - name: scope + in: query + required: true + description: Notification category to mute. + schema: + type: string + enum: [purchase_approvals, ri_exchange_approvals] + get: + operationId: unsubscribeNotification + tags: [Notifications] + summary: Unsubscribe from a notification category (email link) + description: > + Verifies the signed token, records the mute for the email and scope, + and returns an HTML confirmation page. Errors are returned as JSON. + security: [] + responses: + '200': + description: Unsubscribed; HTML confirmation page + content: + text/html: + schema: + type: string + '400': + $ref: '#/components/responses/BadRequest' + '401': + description: Invalid unsubscribe token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: > + Server error, including a deployment with no unsubscribe signing + secret configured. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + operationId: unsubscribeNotificationOneClick + tags: [Notifications] + summary: One-click unsubscribe (RFC 8058) + description: > + The POST a mail client sends for a List-Unsubscribe-Post header. + Same query parameters, effect and responses as the GET. The body + must be exactly List-Unsubscribe=One-Click. + security: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [List-Unsubscribe] + additionalProperties: false + properties: + List-Unsubscribe: + type: string + enum: [One-Click] + responses: + '200': + description: Unsubscribed; HTML confirmation page + content: + text/html: + schema: + type: string + '400': + $ref: '#/components/responses/BadRequest' + '401': + description: Invalid unsubscribe token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: > + Server error, including a deployment with no unsubscribe signing + secret configured. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + # ---- Health ------------------------------------------------------------- + /health: + get: + operationId: healthCheckRoot + tags: [Health] + summary: Health check (root path) + security: [] + responses: + '200': + description: Health status + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + + /ready: + get: + operationId: readinessCheckRoot + tags: [Health] + summary: Readiness check (root path) + description: >- + Traffic-admission contract: answers 200 with the same payload as + /health once this replica has completed initialization, 503 while any + store check is pending or failed or migrations have not run yet. A + failed migration run is reported in the body but still answers 200. + /health is the liveness counterpart and always answers 200. + security: [] + responses: + '200': + description: Replica is ready to serve traffic + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + '503': + description: Replica is still initializing or a store check failed + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + + /api/health: + get: + operationId: healthCheck + tags: [Health] + summary: Health check + security: [] + responses: + '200': + description: Health status + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + + # ---- Public Info -------------------------------------------------------- + /api/info: + get: + operationId: getPublicInfo + tags: [Info] + summary: Public instance information (version, admin status — no auth required) + security: [] + responses: + '200': + description: Public info + content: + application/json: + schema: + $ref: '#/components/schemas/PublicInfoResponse' + + /api/info/deployment: + get: + operationId: getDeploymentInfo + tags: [Info] + summary: Deployment-scoped identifiers (AuthUser required) + description: > + Returns the Secrets Manager console URL and the deployment AWS account ID. + Requires at least a valid user session. Not accessible to unauthenticated + callers (closes #633). + security: + - bearerAuth: [] + - apiKeyAuth: [] + responses: + '200': + description: Deployment info + content: + application/json: + schema: + $ref: '#/components/schemas/DeploymentInfoResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /version: + get: + operationId: getVersion + tags: [Info] + summary: Build version, git commit and build time (no auth required) + security: [] + responses: + '200': + description: Build identity + content: + application/json: + schema: + $ref: '#/components/schemas/VersionResponse' + + # ---- Docs -------------------------------------------------------------- + /api/docs: + get: + operationId: swaggerUI + tags: [Docs] + summary: Swagger UI + security: [] + responses: + '200': + description: HTML page + content: + text/html: + schema: + type: string + head: + operationId: swaggerUIHead + tags: [Docs] + summary: Swagger UI (headers only) + security: [] + responses: + '200': + description: Same headers as GET, empty body + + /api/docs/openapi.yaml: + get: + operationId: getOpenAPISpec + tags: [Docs] + summary: Raw OpenAPI specification + security: [] + responses: + '200': + description: OpenAPI YAML + content: + application/yaml: + schema: + type: string + head: + operationId: getOpenAPISpecHead + tags: [Docs] + summary: Raw OpenAPI specification (headers only) + security: [] + responses: + '200': + description: Same headers as GET, empty body + + /docs: + get: + operationId: swaggerUIAlias + tags: [Docs] + summary: Swagger UI (alias) + security: [] + responses: + '200': + description: HTML page + content: + text/html: + schema: + type: string + head: + operationId: swaggerUIAliasHead + tags: [Docs] + summary: Swagger UI (alias) (headers only) + security: [] + responses: + '200': + description: Same headers as GET, empty body + + /docs/openapi.yaml: + get: + operationId: getOpenAPISpecAlias + tags: [Docs] + summary: Raw OpenAPI specification (alias) + security: [] + responses: + '200': + description: OpenAPI YAML + content: + application/yaml: + schema: + type: string + head: + operationId: getOpenAPISpecAliasHead + tags: [Docs] + summary: Raw OpenAPI specification (alias) (headers only) + security: [] + responses: + '200': + description: Same headers as GET, empty body + +# --------------------------------------------------------------------------- +# Components +# --------------------------------------------------------------------------- +components: + # ---- Security Schemes --------------------------------------------------- + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: Session token returned by POST /api/auth/login + apiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: API key created via POST /api/api-keys + + # ---- Reusable Parameters ------------------------------------------------ + parameters: + ResourceID: + name: id + in: path + required: true + schema: + type: string + format: uuid + + AccountID: + name: account_id + in: query + schema: + type: string + + StartDate: + name: start + in: query + description: > + Range start, as an RFC 3339 timestamp or YYYY-MM-DD. Defaults to 7 + days before end. The range may span at most 366 days. + schema: + type: string + + EndDate: + name: end + in: query + description: > + Range end, as an RFC 3339 timestamp or YYYY-MM-DD (a date includes + the whole day). Defaults to now. + schema: + type: string + + AnalyticsAccountID: + name: account_id + in: query + description: > + Cloud account UUID. Required for callers restricted to specific + accounts; empty means all accounts for unrestricted callers. + schema: + type: string + + HistoryProvider: + name: provider + in: query + description: Filter by cloud provider; "all" or empty means no filter. + schema: + type: string + enum: [aws, azure, gcp, all] + + CSRFToken: + name: X-CSRF-Token + in: header + required: true + description: CSRF token from the login response + schema: + type: string + + # ---- Reusable Responses ------------------------------------------------- + responses: + BadRequest: + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Unauthorized: + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Forbidden: + description: Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + UnprocessableEntity: + description: Request understood but rejected by a business-rule guardrail + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Conflict: + description: > + An identical submit already holds the idempotency claim, so THIS + request was not executed. The claim is retained unconditionally once + taken, including when the earlier submit failed, so this response + asserts nothing about that submit's outcome: it may still be running, + it may have committed, its outcome may be unresolved (the provider + call can fail after the operation was already submitted), or it may + have failed without committing anything. Do not read a 409 as + confirmation that the earlier submit succeeded. Verify its outcome + with the provider before resubmitting. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + EditorReconciliationRequired: + description: > + The plan is managed by the schedule editor and this legacy change + needs the editor (code editor_reconciliation_required). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorWithCode' + InternalError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + RateLimited: + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + RateLimiterUnavailable: + description: > + The rate limiter could not be reached, so this credential endpoint + fails closed. Retry later. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + AnalyticsUnavailable: + description: Analytics storage is not configured on this deployment. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + # ---- Schemas ------------------------------------------------------------ + schemas: + # -- Azure RI exchange -------------------------------------------------- + AzureExchangeSource: + type: object + description: One Azure VM reservation to hand back in an exchange. + required: [reservation_id, quantity] + properties: + reservation_id: + type: string + description: > + Full ARM resource id of the reservation item, as returned by + GET /api/ri-exchange/azure-instances, e.g. + /providers/Microsoft.Capacity/reservationOrders/{orderId}/reservations/{reservationId} + quantity: + type: integer + format: int32 + minimum: 1 + description: Number of instances to hand back. + + AzureExchangeTarget: + type: object + description: One Azure VM reservation to acquire in an exchange. + required: [sku, location, term, quantity] + properties: + sku: + type: string + description: VM size to purchase, e.g. Standard_D4s_v3. + location: + type: string + description: Azure region, e.g. eastus. + term: + type: string + description: > + Reservation term, validated against the Azure SDK's own term + enum; any other value is a 400. Which of these Azure actually + accepts depends on the resource type (VM reservations are sold + for one and three years), so a term Azure does not offer for + this SKU comes back as a policy error from the pricing call + rather than being rejected here. + enum: [P1Y, P3Y, P5Y] + quantity: + type: integer + format: int32 + minimum: 1 + description: Number of instances to reserve. + billing_scope_id: + type: string + description: > + Optional, and NOT the scope that gets charged: the billing scope + is always derived from subscription_id. Supplying a value that + names a different subscription is a 400, so a caller cannot + direct the charge away from the subscription their permission + constraints were evaluated against. Omit it to charge the + subscription's own scope. + + # -- Generic ------------------------------------------------------------ + Error: + type: object + required: [error] + properties: + error: + type: string + + ErrorWithCode: + type: object + required: [error] + properties: + error: + type: string + code: + type: string + description: Machine-readable reason, for example editor_reconciliation_required. + + StatusResponse: + type: object + required: [status] + properties: + status: + type: string + + DeletePlannedPurchaseResponse: + type: object + required: [status] + properties: + status: + type: string + plan_disabled: + type: boolean + description: >- + Present when the execution belongs to a plan. True when the plan + was disabled; false when it was left enabled because it spans + accounts outside the caller's scope. Omitted for executions + without a plan. + + StatusMessageResponse: + type: object + properties: + status: + type: string + message: + type: string + + # -- Auth --------------------------------------------------------------- + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + description: Base64-encoded password + mfa_code: + type: string + + LoginResponse: + type: object + properties: + token: + type: string + expires_at: + type: string + format: date-time + user: + $ref: '#/components/schemas/UserInfo' + csrf_token: + type: string + + UserInfo: + type: object + properties: + id: + type: string + email: + type: string + format: email + role: + type: string + enum: [admin, user, readonly] + groups: + type: array + items: + type: string + mfa_enabled: + type: boolean + + SetupAdminRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + description: Base64-encoded password + + PasswordResetRequest: + type: object + required: [email] + properties: + email: + type: string + format: email + + PasswordResetConfirm: + type: object + required: [token, new_password] + properties: + token: + type: string + new_password: + type: string + description: Base64-encoded new password + + ProfileUpdateRequest: + type: object + required: [email, current_password] + properties: + email: + type: string + format: email + current_password: + type: string + description: Base64-encoded current password + new_password: + type: string + description: Base64-encoded new password (optional) + + ChangePasswordRequest: + type: object + required: [current_password, new_password] + properties: + current_password: + type: string + description: Base64-encoded current password + new_password: + type: string + description: Base64-encoded new password + + CurrentUserResponse: + type: object + properties: + id: + type: string + email: + type: string + format: email + role: + type: string + mfa_enabled: + type: boolean + + AdminExistsResponse: + type: object + properties: + admin_exists: + type: boolean + + # -- Users / Groups ----------------------------------------------------- + User: + type: object + properties: + id: + type: string + email: + type: string + format: email + role: + type: string + enum: [admin, user, readonly] + groups: + type: array + items: + type: string + mfa_enabled: + type: boolean + active: + type: boolean + description: Whether the account is active. A deactivated user's existing sessions are revoked and cannot be restored via password reset. + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + CreateUserRequest: + type: object + required: [email, password, role] + properties: + email: + type: string + format: email + password: + type: string + description: Base64-encoded password + role: + type: string + enum: [admin, user, readonly] + groups: + type: array + items: + type: string + + UpdateUserRequest: + type: object + properties: + email: + type: string + format: email + role: + type: string + enum: [admin, user, readonly] + groups: + type: array + items: + type: string + active: + type: boolean + description: Set false to deactivate the user (revokes their existing sessions); set true to reactivate. Omit to leave unchanged. + + Group: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + permissions: + type: array + items: + $ref: '#/components/schemas/Permission' + allowed_accounts: + type: array + items: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + CreateGroupRequest: + type: object + required: [name, permissions] + properties: + name: + type: string + description: + type: string + permissions: + type: array + items: + $ref: '#/components/schemas/Permission' + allowed_accounts: + type: array + items: + type: string + + UpdateGroupRequest: + type: object + properties: + name: + type: string + description: + type: string + permissions: + type: array + items: + $ref: '#/components/schemas/Permission' + allowed_accounts: + type: array + items: + type: string + + Permission: + type: object + required: [action, resource] + properties: + action: + type: string + resource: + type: string + constraints: + $ref: '#/components/schemas/PermissionConstraint' + + PermissionConstraint: + type: object + properties: + accounts: + type: array + items: + type: string + providers: + type: array + items: + type: string + services: + type: array + items: + type: string + regions: + type: array + items: + type: string + max_amount: + type: number + format: double + minimum: 0 + exclusiveMinimum: true + description: >- + Spend cap per purchase. Omit for no cap. An explicit 0 is rejected + with 400 on group writes (create and update); API-key creation + over HTTP does not enforce this yet. + + # -- API Keys ----------------------------------------------------------- + CreateAPIKeyRequest: + type: object + required: [name, password, permissions, expires_at] + properties: + name: + type: string + password: + type: string + description: Base64-encoded password, re-verified before minting the key + permissions: + type: array + items: + $ref: '#/components/schemas/Permission' + minItems: 1 + description: | + At least one permission is required. An unscoped key would + authenticate its owner but authorize nothing (issue #61), so the + API rejects an empty or missing list with 400 rather than + accepting a key that can never be used. + expires_at: + type: string + format: date-time + description: Required and capped at the server-side maximum API key lifetime + + CreateAPIKeyResponse: + type: object + properties: + api_key: + type: string + description: Full key value (shown only once) + key_id: + type: string + info: + $ref: '#/components/schemas/APIKeyInfo' + + APIKeyInfo: + type: object + properties: + id: + type: string + name: + type: string + key_prefix: + type: string + description: First 8 characters for display + permissions: + type: array + items: + $ref: '#/components/schemas/Permission' + expires_at: + type: string + format: date-time + created_at: + type: string + format: date-time + last_used_at: + type: string + format: date-time + is_active: + type: boolean + request_count_total: + type: integer + format: int64 + nullable: true + description: | + Lifetime request count since the key was created. Null when the + count is unknown: keys already in use before migration 000094 + introduced the counter have no recoverable lifetime figure, and + reporting 0 for them would assert a volume nobody measured. A key + that has genuinely never been used reports 0, not null. + request_count_window: + type: integer + format: int64 + description: | + Request count since request_count_window_start. This is a + FIXED/TUMBLING window, not a true trailing-24h rolling count: + once the window is older than 24h, the next request restarts + both this count and request_count_window_start, discarding + whatever count the previous window had even if some of those + requests happened within the preceding 24h. + + Reported as 0 once the window is older than 24h, even though + the underlying row still holds the closed window's count until + the key is used again: a key that went idle has no current + activity. Use request_count_window_start to see exactly which + period this count covers. + request_count_window_start: + type: string + format: date-time + nullable: true + description: | + Timestamp the current request_count_window began at. Null when + the key has never recorded a request, and also null once the + window has closed, since there is then no current period for + the count to refer to. + + APIKeysUsageStats: + type: object + properties: + total_active: + type: integer + description: | + Number of the caller's API keys that are currently usable, i.e. + neither revoked nor past their expiry. Matches the keys the + authentication path will accept. + total_requests_window: + type: integer + format: int64 + description: | + Sum of request_count_window across the caller's keys, counting + only windows that are still open. Each summand is a fixed-window + count (see APIKeyInfo.request_count_window), not a true rolling + 24h total, and the windows are not aligned with each other. + total_requests_lifetime: + type: integer + format: int64 + description: | + Sum of request_count_total across the caller's keys, counting only + the keys whose lifetime count is known. See lifetime_partial. + lifetime_partial: + type: boolean + description: | + True when at least one key was excluded from total_requests_lifetime + because its usage predates the counters, making that total a lower + bound rather than an exact figure. + top_keys: + type: array + description: | + Top keys by request_count_window (lifetime count as + tiebreaker). At most 3 entries; keys with zero window + activity are omitted. + items: + type: object + properties: + id: + type: string + name: + type: string + key_prefix: + type: string + request_count_window: + type: integer + format: int64 + + # -- Configuration ------------------------------------------------------ + ConfigResponse: + type: object + properties: + global: + $ref: '#/components/schemas/GlobalConfig' + services: + type: array + items: + $ref: '#/components/schemas/ServiceConfig' + + GlobalConfig: + type: object + properties: + enabled_providers: + type: array + items: + type: string + enum: [aws, azure, gcp] + notification_email: + type: string + format: email + nullable: true + approval_required: + type: boolean + default_term: + type: integer + description: Term in years (1 or 3) + default_payment: + type: string + enum: [no-upfront, partial-upfront, all-upfront] + default_coverage: + type: number + format: double + description: Target coverage percentage (0-100) + default_ramp_schedule: + type: string + enum: [immediate, weekly-25pct, monthly-10pct, custom] + + ServiceConfig: + type: object + properties: + provider: + type: string + service: + type: string + enabled: + type: boolean + term: + type: integer + payment: + type: string + coverage: + type: number + format: double + ramp_schedule: + type: string + include_engines: + type: array + items: + type: string + exclude_engines: + type: array + items: + type: string + include_regions: + type: array + items: + type: string + exclude_regions: + type: array + items: + type: string + include_types: + type: array + items: + type: string + exclude_types: + type: array + items: + type: string + min_count: + type: integer + minimum: 0 + description: | + Minimum instance/node count a recommendation must carry to be + surfaced (GUI/persisted equivalent of the CLI --min-count flag). + 0 (default) disables the filter. + + CommitmentOptionsResponse: + type: object + required: [status] + properties: + status: + type: string + enum: [ok, unavailable] + description: | + `ok` means `aws` carries the probed combos. `unavailable` means + the probe hasn't run (no AWS account connected) or the most + recent attempt failed; the frontend should use hardcoded + defaults. + aws: + type: object + description: | + Per-service supported (term, payment) tuples. Keys are service + identifiers (rds, elasticache, opensearch, redshift, memorydb, + ec2). Omitted when status is `unavailable`. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/CommitmentOptionCombo' + + CommitmentOptionCombo: + type: object + required: [term, payment] + properties: + term: + type: integer + enum: [1, 3] + description: Commitment length in years. + payment: + type: string + enum: [all-upfront, partial-upfront, no-upfront] + + # -- Dashboard ---------------------------------------------------------- + DashboardSummary: + type: object + properties: + potential_monthly_savings: + type: number + format: double + total_recommendations: + type: integer + active_commitments: + type: integer + committed_monthly: + type: number + format: double + current_coverage: + type: number + format: double + target_coverage: + type: number + format: double + ytd_savings: + type: number + format: double + by_service: + type: object + additionalProperties: + $ref: '#/components/schemas/ServiceSavings' + + ServiceSavings: + type: object + properties: + potential_savings: + type: number + format: double + current_savings: + type: number + format: double + + UpcomingPurchaseResponse: + type: object + properties: + purchases: + type: array + items: + $ref: '#/components/schemas/UpcomingPurchase' + + UpcomingPurchase: + type: object + properties: + execution_id: + type: string + plan_name: + type: string + scheduled_date: + type: string + format: date + provider: + type: string + service: + type: string + step_number: + type: integer + total_steps: + type: integer + estimated_savings: + type: number + format: double + has_recommendations: + type: boolean + description: > + False for a plan step that carries no recommendations; + estimated_savings is then a placeholder, not data (platform#609). + + # -- Recommendations ---------------------------------------------------- + RecommendationsResponse: + type: object + properties: + recommendations: + type: array + items: + $ref: '#/components/schemas/RecommendationRecord' + total_savings: + type: number + format: double + count: + type: integer + + RecommendationRecord: + type: object + properties: + id: + type: string + provider: + type: string + service: + type: string + region: + type: string + resource_type: + type: string + engine: + type: string + cloud_account_id: + type: string + nullable: true + description: > + The cloud account this recommendation belongs to. Matched + against the stored recommendation set together with provider, + service, region, resource_type, engine, term and payment + (#1905); an account-scoped recommendation cannot be resolved + without it. Null or omitted only for recommendations that are + not tied to a specific account. + count: + type: integer + recommended_count: + type: integer + description: > + The pre-scaling count originally recommended, before any + Capacity % scaling was applied to produce count. Optional: + omitted or 0 means "not supplied" and the capacity-consistency + check is skipped for this recommendation. For a Savings Plan, + the stored value of this field is also the count that count + must equal, since Savings Plans are priced by hourly commitment + rather than by count. + term: + type: integer + payment: + type: string + upfront_cost: + type: number + format: double + monthly_cost: + type: number + format: double + savings: + type: number + format: double + selected: + type: boolean + purchased: + type: boolean + purchase_id: + type: string + error: + type: string + + RefreshResponse: + type: object + required: [started_at, last_collected_at] + properties: + started_at: + type: string + format: date-time + description: When this collection started. + last_collected_at: + type: string + format: date-time + nullable: true + description: Previous successful collection, or null if none. + + RecommendationsFreshness: + type: object + required: [last_collected_at, last_collection_error, last_collection_started_at, last_collection_partial] + properties: + last_collected_at: + type: string + format: date-time + nullable: true + description: When recommendations were last persisted; null on a cold start. + last_collection_error: + type: string + nullable: true + description: Error from the most recent collection when a provider failed; null otherwise. + last_collection_started_at: + type: string + format: date-time + nullable: true + description: Set while a collection is in flight. + last_collection_partial: + nullable: true + description: > + Non-null when the most recent persisted collection kept rows from + at least one incomplete sweep. Cleared by the next collection whose + sweeps are all complete. Carries counts and reason categories only. + allOf: + - $ref: '#/components/schemas/RecommendationsCollectionPartial' + + RecommendationsCollectionPartial: + type: object + required: [providers] + properties: + providers: + type: array + items: + $ref: '#/components/schemas/PartialProviderSweep' + + PartialProviderSweep: + type: object + required: [provider, incomplete_accounts, attempted_scopes, failed_scopes, failed_details, reasons] + properties: + provider: + type: string + enum: [aws, azure, gcp] + incomplete_accounts: + type: integer + description: Accounts (or the ambient host identity) whose sweep was incomplete. + attempted_scopes: + type: integer + description: > + Scopes (Azure subscriptions, GCP projects) the incomplete sweeps + were asked to cover; 0 when the provider does not report it (AWS). + failed_scopes: + type: integer + description: Scopes that could not be queried. + failed_details: + type: integer + description: Individual recommendation details rejected inside answering scopes (AWS). + reasons: + type: object + description: > + Failure count per reason category. Keys are drawn from a closed + set: permission_denied, throttled, timeout, canceled, not_found, + provider_unavailable, other. + additionalProperties: + type: integer + example: + permission_denied: 2 + throttled: 1 + + # -- Plans -------------------------------------------------------------- + PlansResponse: + type: object + properties: + plans: + type: array + items: + $ref: '#/components/schemas/PlanWithHealth' + + PlanRequest: + type: object + required: [name] + properties: + name: + type: string + description: + type: string + enabled: + type: boolean + auto_purchase: + type: boolean + notification_days_before: + type: integer + max_step_commitment: + type: number + nullable: true + exclusiveMinimum: 0 + description: >- + Ceiling on the total commitment (upfront + monthly * term) one ramp + step may auto-purchase. Omit on create for no cap (auto-purchase of + steps with recommendations is then refused); omit on update to keep + the current cap. On update, explicit null clears a manual plan's + cap and is refused while auto_purchase is true. + provider: + type: string + service: + type: string + term: + type: integer + payment: + type: string + target_coverage: + type: integer + ramp_schedule: + type: string + enum: [immediate, weekly-25pct, monthly-10pct, custom] + custom_step_percent: + type: integer + custom_interval_days: + type: integer + target_accounts: + type: array + minItems: 1 + description: > + UUIDs of cloud accounts the plan will purchase for. Required on + POST /plans — a plan must be tied to at least one account. Sending + an empty array returns HTTP 400. + items: + type: string + format: uuid + + PurchasePlan: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + enabled: + type: boolean + auto_purchase: + type: boolean + notification_days_before: + type: integer + max_step_commitment: + type: number + nullable: true + description: Cap on the total commitment one ramp step may auto-purchase; null means auto-purchase of steps with recommendations is refused. + services: + type: object + additionalProperties: + $ref: '#/components/schemas/ServiceConfig' + ramp_schedule: + $ref: '#/components/schemas/RampSchedule' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + next_execution_date: + type: string + format: date-time + nullable: true + last_execution_date: + type: string + format: date-time + nullable: true + last_notification_sent: + type: string + format: date-time + nullable: true + + # PlanWithHealth wraps PurchasePlan with a read-time-only health-score + # badge (issue #340 follow-up): score/factors are computed from the + # plan's attributes and recent executions, never persisted, so they + # can't drift into the PurchasePlan schema above. + PlanWithHealth: + allOf: + - $ref: '#/components/schemas/PurchasePlan' + - type: object + properties: + health_score: + type: integer + nullable: true + minimum: 0 + maximum: 100 + description: > + 0-100 plan health score. Green >= 80, amber 50-79, red < 50. + Null when the score could not be computed because one of its + required inputs was unavailable: the plan's execution counts, + or the stuck-ramp report that ramp_blocked is derived from. + Every factor only ever subtracts, so a score computed without + one of them would read healthier than the plan is; the whole + score is withheld instead. Clients must render null as + "unknown" rather than substituting a default. + health_factors: + type: array + items: + $ref: '#/components/schemas/PlanHealthFactor' - # ---- Reusable Parameters ------------------------------------------------ - parameters: - ResourceID: - name: id - in: path - required: true - schema: - type: string - format: uuid + PlanHealthFactor: + type: object + description: One penalty subtracted from a plan's health score, named so a bad score is actionable instead of opaque. + properties: + code: + type: string + description: > + Which check produced the penalty. The two execution-based codes + (failed_executions, canceled_executions) count only executions + that entered that state within the trailing execution-retention + window (30 days), not over all history, so they describe the + plan's recent behaviour rather than its lifetime record. + ramp_blocked is derived from the execution rows of the plan's next + ramp step, not time-windowed, and pre-empts stalled / + behind_schedule: it means the step cannot complete because one of + its executions (one per cloud account, once the step has fanned + out) failed with no retry in flight, as opposed to the plan merely + running late. The remaining codes are derived from the plan's + current attributes and ramp schedule. + enum: [overdue, failed_executions, canceled_executions, ramp_blocked, stalled, behind_schedule, disabled_midway] + penalty: + type: integer + description: Points subtracted from 100 by this factor. + note: + type: string + description: > + Human-readable reason for display in the badge tooltip. Free + text, and its wording may change; clients must branch on `code` + rather than parsing this. - AccountID: - name: account_id - in: query - schema: - type: string + RampSchedule: + type: object + properties: + type: + type: string + enum: [immediate, weekly, monthly, custom] + percent_per_step: + type: number + format: double + step_interval_days: + type: integer + current_step: + type: integer + total_steps: + type: integer + start_date: + type: string + format: date-time - StartDate: - name: start_date - in: query - description: RFC 3339 or YYYY-MM-DD - schema: - type: string - format: date + CreatePlannedPurchasesRequest: + type: object + properties: + count: + type: integer + start_date: + type: string + format: date + description: > + First purchase date (YYYY-MM-DD), interpreted as midnight UTC. + Must be after the current time, so the earliest accepted value + is tomorrow's date in UTC. - EndDate: - name: end_date - in: query - description: RFC 3339 or YYYY-MM-DD - schema: - type: string - format: date + CreatePlannedPurchasesResponse: + type: object + properties: + created: + type: integer - CSRFToken: - name: X-CSRF-Token - in: header - required: true - description: CSRF token from the login response - schema: - type: string + # -- Purchases ---------------------------------------------------------- + ExecutePurchaseRequest: + type: object + required: [recommendations] + properties: + recommendations: + type: array + items: + $ref: '#/components/schemas/RecommendationRecord' + minItems: 1 + maxItems: 1000 + description: > + Each item must match a recommendation in the current recommendation set on + (provider, cloud_account_id, service, region, resource_type, engine, term, + payment). Only count, recommended_count and selected are honoured; every other + field (including id, details, upfront_cost, monthly_cost, savings, + on_demand_cost, purchased, purchase_id and error) is replaced server-side from + the stored recommendation, with the cost fields scaled by count. The spend-cap + check, execution record and approval email use those replaced values. Savings + plan items must keep the recommended count. - # ---- Reusable Responses ------------------------------------------------- - responses: - BadRequest: - description: Invalid request - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - Unauthorized: - description: Authentication required - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - Forbidden: - description: Insufficient permissions - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - NotFound: - description: Resource not found - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - UnprocessableEntity: - description: Request understood but rejected by a business-rule guardrail - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - Conflict: - description: > - An identical submit already holds the idempotency claim, so THIS - request was not executed. The claim is retained unconditionally once - taken, including when the earlier submit failed, so this response - asserts nothing about that submit's outcome: it may still be running, - it may have committed, its outcome may be unresolved (the provider - call can fail after the operation was already submitted), or it may - have failed without committing anything. Do not read a 409 as - confirmation that the earlier submit succeeded. Verify its outcome - with the provider before resubmitting. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - EditorReconciliationRequired: - description: > - The plan is managed by the schedule editor and this legacy change - needs the editor (code editor_reconciliation_required). - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorWithCode' - InternalError: - description: Internal server error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - RateLimited: - description: Rate limit exceeded - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + ExecutePurchaseResponse: + type: object + properties: + execution_id: + type: string + format: uuid + status: + type: string + recommendation_count: + type: integer + total_upfront_cost: + type: number + format: double + estimated_savings: + type: number + format: double + message: + type: string - # ---- Schemas ------------------------------------------------------------ - schemas: - # -- Azure RI exchange -------------------------------------------------- - AzureExchangeSource: + PurchaseExecution: + type: object + properties: + execution_id: + type: string + format: uuid + plan_id: + type: string + plan_name: + type: string + status: + type: string + # Migration 000089 (expand-contract rename, cancelled -> canceled): + # both spellings are valid during the rolling deploy window. New code + # writes "canceled" (US); rows written by old code retain "cancelled" + # until the CONTRACT migration (#1278) normalizes them, at which + # point the legacy entry can be removed from this enum. + enum: [pending, notified, approved, canceled, cancelled, completed, failed, running, paused] + step_number: + type: integer + scheduled_date: + type: string + format: date + total_upfront_cost: + type: number + format: double + estimated_savings: + type: number + format: double + recommendations: + type: array + items: + $ref: '#/components/schemas/RecommendationRecord' + notification_sent: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + error: + type: string + + PlannedPurchasesResponse: + type: object + properties: + purchases: + type: array + items: + $ref: '#/components/schemas/PlannedPurchase' + + PlannedPurchase: type: object - description: One Azure VM reservation to hand back in an exchange. - required: [reservation_id, quantity] properties: - reservation_id: + id: + type: string + format: uuid + plan_id: + type: string + plan_name: + type: string + scheduled_date: + type: string + format: date + provider: + type: string + service: + type: string + resource_type: + type: string + region: + type: string + count: + type: integer + term: + type: integer + payment: type: string + estimated_savings: + type: number + format: double + upfront_cost: + type: number + format: double + has_recommendations: + type: boolean description: > - Full ARM resource id of the reservation item, as returned by - GET /api/ri-exchange/azure-instances, e.g. - /providers/Microsoft.Capacity/reservationOrders/{orderId}/reservations/{reservationId} - quantity: + False for a plan step that carries no recommendations; count, + resource_type, upfront_cost and estimated_savings are then + placeholders, not data (platform#609). + status: + type: string + step_number: + type: integer + total_steps: type: integer - format: int32 - minimum: 1 - description: Number of instances to hand back. - AzureExchangeTarget: + RunPurchaseResponse: type: object - description: One Azure VM reservation to acquire in an exchange. - required: [sku, location, term, quantity] properties: - sku: + execution_id: type: string - description: VM size to purchase, e.g. Standard_D4s_v3. - location: + format: uuid + status: type: string - description: Azure region, e.g. eastus. - term: + message: type: string - description: > - Reservation term, validated against the Azure SDK's own term - enum; any other value is a 400. Which of these Azure actually - accepts depends on the resource type (VM reservations are sold - for one and three years), so a term Azure does not offer for - this SKU comes back as a policy error from the pricing call - rather than being rejected here. - enum: [P1Y, P3Y, P5Y] - quantity: + + # -- History / Analytics ------------------------------------------------ + HistoryResponse: + type: object + properties: + summary: + $ref: '#/components/schemas/HistorySummary' + purchases: + type: array + items: + $ref: '#/components/schemas/PurchaseHistoryRecord' + truncated: + type: boolean + description: >- + True when a fetch reached its row cap, so older rows (and possibly + pending approvals) may be missing from purchases and summary. May + be a false positive when exactly `limit` or `executions_limit` rows exist. + Also true when the actionable executions class (pending, approved, + running, ...) reached its own cap of 1000 rows. + limit: type: integer - format: int32 - minimum: 1 - description: Number of instances to reserve. - billing_scope_id: - type: string - description: > - Optional, and NOT the scope that gets charged: the billing scope - is always derived from subscription_id. Supplying a value that - names a different subscription is a 400, so a caller cannot - direct the charge away from the subscription their permission - constraints were evaluated against. Omit it to charge the - subscription's own scope. + description: Row cap on completed purchase history rows for this request. + executions_limit: + type: integer + description: >- + Row cap on terminal executions (failed, expired, canceled, ...), + fixed and ignoring the limit parameter. Actionable executions use a + separate cap of 1000. - # -- Generic ------------------------------------------------------------ - Error: + HistorySummary: type: object - required: [error] properties: - error: + total_purchases: + type: integer + total_upfront: + type: number + format: double + nullable: true + total_monthly_savings: + type: number + format: double + total_annual_savings: + type: number + format: double + + PurchaseHistoryRecord: + type: object + properties: + account_id: + type: string + purchase_id: + type: string + timestamp: + type: string + format: date-time + provider: + type: string + service: + type: string + region: + type: string + resource_type: + type: string + count: + type: integer + term: + type: integer + payment: + type: string + upfront_cost: + type: number + format: double + nullable: true + monthly_cost: + type: number + format: double + estimated_savings: + type: number + format: double + plan_id: + type: string + plan_name: type: string + ramp_step: + type: integer - ErrorWithCode: + AnalyticsResponse: type: object - required: [error] properties: - error: + start: type: string - code: + format: date-time + end: type: string - description: Machine-readable reason, for example editor_reconciliation_required. + format: date-time + interval: + type: string + summary: + $ref: '#/components/schemas/HistorySummary' + data_points: + type: array + items: + $ref: '#/components/schemas/HistoryDataPoint' - StatusResponse: + HistoryDataPoint: type: object - required: [status] properties: - status: + timestamp: type: string + format: date-time + total_savings: + type: number + format: double + total_upfront: + type: number + format: double + nullable: true + purchase_count: + type: integer + cumulative_savings: + type: number + format: double + by_service: + type: object + additionalProperties: + type: number + format: double + by_provider: + type: object + additionalProperties: + type: number + format: double - DeletePlannedPurchaseResponse: + BreakdownResponse: type: object - required: [status] properties: - status: + dimension: type: string - plan_disabled: - type: boolean - description: >- - Present when the execution belongs to a plan. True when the plan - was disabled; false when it was left enabled because it spans - accounts outside the caller's scope. Omitted for executions - without a plan. + start: + type: string + format: date-time + end: + type: string + format: date-time + data: + type: object + additionalProperties: + $ref: '#/components/schemas/BreakdownValue' - StatusMessageResponse: + BreakdownValue: + type: object + properties: + total_savings: + type: number + format: double + total_upfront: + type: number + format: double + nullable: true + purchase_count: + type: integer + percentage: + type: number + format: double + + # -- Health ------------------------------------------------------------- + HealthResponse: type: object properties: status: type: string - message: + enum: [healthy, degraded] + timestamp: type: string + format: date-time + checks: + type: object + additionalProperties: + $ref: '#/components/schemas/HealthCheck' - # -- Auth --------------------------------------------------------------- - LoginRequest: + HealthCheck: type: object - required: [email, password] properties: - email: - type: string - format: email - password: + status: type: string - description: Base64-encoded password - mfa_code: + enum: [healthy, unhealthy] + message: type: string - LoginResponse: + # -- Info --------------------------------------------------------------- + PublicInfoResponse: type: object + description: > + Unauthenticated public info. Only safe-to-expose fields are included. + Sensitive identifiers (API key secret URL, AWS account ID) are served + by the authenticated /api/info/deployment endpoint (#633). properties: - token: + version: type: string - expires_at: + admin_exists: + type: boolean + + DeploymentInfoResponse: + type: object + description: > + Deployment-scoped identifiers. Requires AuthUser. Not accessible to + unauthenticated callers (#633). + properties: + api_key_secret_url: type: string - format: date-time - user: - $ref: '#/components/schemas/UserInfo' - csrf_token: + description: AWS Console deep-link to the Secrets Manager secret holding the API key. + deployment_aws_account_id: type: string + description: > + AWS account ID of the CUDly Lambda deployment (from STS GetCallerIdentity). + Present only on AWS-hosted deployments; omitted on Azure/GCP or when STS + is unreachable. Used by the frontend to distinguish legitimate ambient-credential + executions (cloud_account_id IS NULL because the rec targets this account) + from orphan rows whose account was deleted (issue #608). - UserInfo: + CloudAccount: type: object + description: A managed cloud account (AWS account, Azure subscription or GCP project). + required: [id, name, enabled, provider, external_id, credentials_configured, created_at, updated_at] properties: id: type: string - email: + format: uuid + name: type: string - format: email - role: + description: type: string - enum: [admin, user, readonly] - groups: - type: array - items: - type: string - mfa_enabled: - type: boolean - - SetupAdminRequest: - type: object - required: [email, password] - properties: - email: + contact_email: type: string - format: email - password: + enabled: + type: boolean + provider: type: string - description: Base64-encoded password - - PasswordResetRequest: - type: object - required: [email] - properties: - email: + enum: [aws, azure, gcp] + external_id: type: string - format: email - - PasswordResetConfirm: - type: object - required: [token, new_password] - properties: - token: + description: AWS account ID, Azure subscription ID or GCP project ID. + aws_auth_mode: type: string - new_password: + description: Empty for accounts imported by org discovery until configured. + aws_role_arn: type: string - description: Base64-encoded new password - - ProfileUpdateRequest: - type: object - required: [email, current_password] - properties: - email: + aws_external_id: type: string - format: email - current_password: + description: sts:ExternalId used when assuming aws_role_arn. + aws_bastion_id: type: string - description: Base64-encoded current password - new_password: + description: ID of the cloud account whose credentials are used as the bastion. + aws_web_identity_token_file: type: string - description: Base64-encoded new password (optional) - - ChangePasswordRequest: - type: object - required: [current_password, new_password] - properties: - current_password: + aws_is_org_root: + type: boolean + azure_subscription_id: type: string - description: Base64-encoded current password - new_password: + azure_tenant_id: type: string - description: Base64-encoded new password - - CurrentUserResponse: - type: object - properties: - id: + azure_client_id: type: string - email: + azure_auth_mode: type: string - format: email - role: + gcp_project_id: type: string - mfa_enabled: - type: boolean - - AdminExistsResponse: - type: object - properties: - admin_exists: - type: boolean - - # -- Users / Groups ----------------------------------------------------- - User: - type: object - properties: - id: + gcp_client_email: type: string - email: + gcp_auth_mode: type: string - format: email - role: + gcp_wif_audience: type: string - enum: [admin, user, readonly] - groups: - type: array - items: - type: string - mfa_enabled: + description: Workload Identity Pool provider resource used as the STS audience. + credentials_configured: type: boolean - active: + description: True when at least one credential is stored for the account. + is_self: type: boolean - description: Whether the account is active. A deactivated user's existing sessions are revoked and cannot be restored via password reset. + description: > + Present (true) on the account that matches CUDly's own host + identity. Set by GET /api/accounts and POST /api/accounts/self only. created_at: type: string format: date-time updated_at: type: string format: date-time + created_by: + type: string - CreateUserRequest: + CloudAccountRequest: type: object - required: [email, password, role] + description: > + Create or replace a cloud account. Provider-specific fields are + stored as sent. For AWS, aws_external_id is required (16 to 1224 + characters from letters, digits and _+=,.@:/-) when aws_auth_mode is + role_arn or bastion and aws_role_arn is set. + required: [name, provider, external_id] properties: - email: + name: + type: string + description: Must not contain control characters. + provider: + type: string + enum: [aws, azure, gcp] + external_id: + type: string + description: > + AWS account ID, Azure subscription ID or GCP project ID. Must not + contain control characters. + description: + type: string + contact_email: type: string format: email - password: + enabled: + type: boolean + description: Defaults to false when omitted. + aws_auth_mode: type: string - description: Base64-encoded password - role: + enum: [access_keys, role_arn, bastion, workload_identity_federation] + aws_role_arn: + type: string + description: IAM role ARN in any AWS partition. + aws_external_id: + type: string + aws_bastion_id: + type: string + aws_web_identity_token_file: + type: string + description: > + Must start with /var/run/secrets/eks.amazonaws.com/serviceaccount/ + or /var/run/secrets/kubernetes.io/serviceaccount/ and must not + contain "..". + aws_is_org_root: + type: boolean + azure_subscription_id: + type: string + azure_tenant_id: + type: string + azure_client_id: + type: string + azure_auth_mode: + type: string + enum: [client_secret, managed_identity, workload_identity_federation] + gcp_project_id: + type: string + gcp_client_email: + type: string + description: Service account email (name@project.iam.gserviceaccount.com). + gcp_auth_mode: + type: string + enum: [service_account, application_default, workload_identity_federation] + gcp_wif_audience: type: string - enum: [admin, user, readonly] - groups: - type: array - items: - type: string - UpdateUserRequest: + AccountSummary: type: object + description: Minimal account projection without credential or configuration fields. + required: [id, name, external_id, provider] properties: - email: + id: type: string - format: email - role: + format: uuid + name: type: string - enum: [admin, user, readonly] - groups: - type: array - items: - type: string - active: + external_id: + type: string + provider: + type: string + enum: [aws, azure, gcp] + + AccountCredentialsRequest: + type: object + required: [credential_type, payload] + properties: + credential_type: + type: string + enum: [aws_access_keys, azure_client_secret, gcp_service_account, gcp_workload_identity_config] + payload: + type: object + additionalProperties: true + description: > + Credential fields, at most two levels deep; unknown keys are + rejected. aws_access_keys requires access_key_id and + secret_access_key. azure_client_secret requires client_secret. + gcp_service_account takes a service account key JSON (type + service_account with project_id, private_key and client_email). + gcp_workload_identity_config takes an external_account credential + configuration JSON (audience, subject_token_type, token_url and a + credential_source object). + + AccountTestResult: + type: object + required: [ok, message] + properties: + ok: type: boolean - description: Set false to deactivate the user (revokes their existing sessions); set true to reactivate. Omit to leave unchanged. + message: + type: string - Group: + AccountServiceOverride: type: object + description: > + Per-account override of the global service configuration. Omitted + fields inherit the global value. + required: [id, account_id, provider, service, created_at, updated_at] properties: id: type: string - name: + format: uuid + account_id: type: string - description: + format: uuid + provider: type: string - permissions: + enum: [aws, azure, gcp] + service: + type: string + enabled: + type: boolean + term: + type: integer + payment: + type: string + coverage: + type: number + format: double + ramp_schedule: + type: string + include_engines: + type: array + items: + type: string + exclude_engines: + type: array + items: + type: string + include_regions: + type: array + items: + type: string + exclude_regions: + type: array + items: + type: string + include_types: type: array items: - $ref: '#/components/schemas/Permission' - allowed_accounts: + type: string + exclude_types: type: array items: type: string @@ -2720,1138 +6776,1215 @@ components: type: string format: date-time - CreateGroupRequest: + AccountServiceOverrideRequest: type: object - required: [name, permissions] + description: Fields to override. Omitted fields inherit the global value. properties: - name: + enabled: + type: boolean + term: + type: integer + payment: type: string - description: + coverage: + type: number + format: double + ramp_schedule: type: string - permissions: - type: array - items: - $ref: '#/components/schemas/Permission' - allowed_accounts: + include_engines: type: array items: type: string - - UpdateGroupRequest: - type: object - properties: - name: - type: string - description: - type: string - permissions: - type: array - items: - $ref: '#/components/schemas/Permission' - allowed_accounts: + exclude_engines: type: array items: type: string - - Permission: - type: object - required: [action, resource] - properties: - action: - type: string - resource: - type: string - constraints: - $ref: '#/components/schemas/PermissionConstraint' - - PermissionConstraint: - type: object - properties: - accounts: + include_regions: type: array items: type: string - providers: + exclude_regions: type: array items: type: string - services: + include_types: type: array items: type: string - regions: + exclude_types: type: array items: type: string - max_amount: - type: number - format: double - minimum: 0 - exclusiveMinimum: true - description: >- - Spend cap per purchase. Omit for no cap. An explicit 0 is rejected - with 400 on group writes (create and update); API-key creation - over HTTP does not enforce this yet. - # -- API Keys ----------------------------------------------------------- - CreateAPIKeyRequest: + DiscoverOrgRequest: type: object - required: [name, password, permissions, expires_at] + required: [account_id] properties: - name: - type: string - password: - type: string - description: Base64-encoded password, re-verified before minting the key - permissions: - type: array - items: - $ref: '#/components/schemas/Permission' - minItems: 1 - description: | - At least one permission is required. An unscoped key would - authenticate its owner but authorize nothing (issue #61), so the - API rejects an empty or missing list with 400 rather than - accepting a key that can never be used. - expires_at: + account_id: type: string - format: date-time - description: Required and capped at the server-side maximum API key lifetime + format: uuid + description: ID of the AWS org-root cloud account (aws_is_org_root true). - CreateAPIKeyResponse: + DiscoverOrgResult: type: object + required: [discovered, created, skipped] properties: - api_key: + discovered: + type: integer + description: Member accounts returned by AWS Organizations. + created: + type: integer + description: New cloud accounts created. + skipped: + type: integer + description: Members already registered (matched by external_id). + + AccountDeleteConflict: + type: object + required: [error, reason] + properties: + error: type: string - description: Full key value (shown only once) - key_id: + reason: type: string - info: - $ref: '#/components/schemas/APIKeyInfo' + enum: [pending_executions, active_executions] + pending_count: + type: integer + description: Pending or notified executions (reason pending_executions only). + pending_execution_ids: + type: array + description: > + IDs of those executions, at most 1000 (reason pending_executions + only; omitted when they could not be listed). + items: + type: string + active_count: + type: integer + description: Approved, running, scheduled or paused executions (reason active_executions only). - APIKeyInfo: + RegistrationRequest: type: object + required: [provider, external_id, account_name, contact_email] properties: - id: + provider: type: string - name: + enum: [aws, azure, gcp] + external_id: type: string - key_prefix: + description: Provider account identifier (AWS account ID, Azure subscription ID or GCP project ID). No control characters. + account_name: type: string - description: First 8 characters for display - permissions: - type: array - items: - $ref: '#/components/schemas/Permission' - expires_at: + description: > + Display name. Line breaks are stripped, surrounding whitespace is + trimmed, and the result is truncated to 256 characters; it must + then be non-empty and free of control characters. + contact_email: type: string - format: date-time - created_at: + description: Address notified of the decision. Must contain @ and be at least 5 characters. + description: type: string - format: date-time - last_used_at: + source_provider: type: string - format: date-time - is_active: - type: boolean - request_count_total: - type: integer - format: int64 - nullable: true - description: | - Lifetime request count since the key was created. Null when the - count is unknown: keys already in use before migration 000094 - introduced the counter have no recoverable lifetime figure, and - reporting 0 for them would assert a volume nobody measured. A key - that has genuinely never been used reports 0, not null. - request_count_window: - type: integer - format: int64 - description: | - Request count since request_count_window_start. This is a - FIXED/TUMBLING window, not a true trailing-24h rolling count: - once the window is older than 24h, the next request restarts - both this count and request_count_window_start, discarding - whatever count the previous window had even if some of those - requests happened within the preceding 24h. - - Reported as 0 once the window is older than 24h, even though - the underlying row still holds the closed window's count until - the key is used again: a key that went idle has no current - activity. Use request_count_window_start to see exactly which - period this count covers. - request_count_window_start: + description: Cloud whose identity CUDly uses to reach this account. + aws_role_arn: type: string - format: date-time - nullable: true - description: | - Timestamp the current request_count_window began at. Null when - the key has never recorded a request, and also null once the - window has closed, since there is then no current period for - the count to refer to. - - APIKeysUsageStats: - type: object - properties: - total_active: - type: integer - description: | - Number of the caller's API keys that are currently usable, i.e. - neither revoked nor past their expiry. Matches the keys the - authentication path will accept. - total_requests_window: - type: integer - format: int64 - description: | - Sum of request_count_window across the caller's keys, counting - only windows that are still open. Each summand is a fixed-window - count (see APIKeyInfo.request_count_window), not a true rolling - 24h total, and the windows are not aligned with each other. - total_requests_lifetime: - type: integer - format: int64 - description: | - Sum of request_count_total across the caller's keys, counting only - the keys whose lifetime count is known. See lifetime_partial. - lifetime_partial: - type: boolean - description: | - True when at least one key was excluded from total_requests_lifetime - because its usage predates the counters, making that total a lower - bound rather than an exact figure. - top_keys: - type: array - description: | - Top keys by request_count_window (lifetime count as - tiebreaker). At most 3 entries; keys with zero window - activity are omitted. - items: - type: object - properties: - id: - type: string - name: - type: string - key_prefix: - type: string - request_count_window: - type: integer - format: int64 + aws_auth_mode: + type: string + aws_external_id: + type: string + azure_subscription_id: + type: string + azure_tenant_id: + type: string + azure_client_id: + type: string + azure_auth_mode: + type: string + gcp_project_id: + type: string + gcp_client_email: + type: string + gcp_auth_mode: + type: string + gcp_wif_audience: + type: string + description: Full Workload Identity Pool provider resource name (federated path only). + credential_type: + type: string + description: Type of the credential in credential_payload, stored for the account on approval. + credential_payload: + type: string + description: > + Optional credential, encrypted at rest and never returned. Moved + to the account's credential store on approval and then wiped + from the registration. - # -- Configuration ------------------------------------------------------ - ConfigResponse: + RegistrationSubmitResponse: type: object + required: [reference_token, status] properties: - global: - $ref: '#/components/schemas/GlobalConfig' - services: - type: array - items: - $ref: '#/components/schemas/ServiceConfig' + reference_token: + type: string + description: 64 hex characters. Use with GET /api/register/{token}. + status: + type: string + enum: [pending] - GlobalConfig: + RegistrationStatusResponse: type: object + required: [status, created_at] properties: - enabled_providers: - type: array - items: - type: string - enum: [aws, azure, gcp] - notification_email: - type: string - format: email - nullable: true - approval_required: - type: boolean - default_term: - type: integer - description: Term in years (1 or 3) - default_payment: + status: type: string - enum: [no-upfront, partial-upfront, all-upfront] - default_coverage: - type: number - format: double - description: Target coverage percentage (0-100) - default_ramp_schedule: + enum: [pending, approved, rejected] + created_at: type: string - enum: [immediate, weekly-25pct, monthly-10pct, custom] + format: date-time + rejection_reason: + type: string + description: Present only when non-empty. - ServiceConfig: + AccountRegistration: type: object + description: > + Registration record as seen by admins. Optional string fields are + omitted when empty. The credential payload is never returned. + required: [id, reference_token, status, provider, external_id, account_name, contact_email, created_at, updated_at] properties: + id: + type: string + format: uuid + reference_token: + type: string + status: + type: string + enum: [pending, approved, rejected] provider: type: string - service: + enum: [aws, azure, gcp] + external_id: type: string - enabled: + account_name: + type: string + contact_email: + type: string + description: + type: string + source_provider: + type: string + aws_role_arn: + type: string + aws_auth_mode: + type: string + aws_external_id: + type: string + azure_subscription_id: + type: string + azure_tenant_id: + type: string + azure_client_id: + type: string + azure_auth_mode: + type: string + gcp_project_id: + type: string + gcp_client_email: + type: string + gcp_auth_mode: + type: string + gcp_wif_audience: + type: string + reg_credential_type: + type: string + description: Type of the credential submitted with the registration. + has_credentials: type: boolean - term: - type: integer - payment: + description: True when a credential type was submitted; omitted otherwise. + rejection_reason: type: string - coverage: - type: number - format: double - ramp_schedule: + cloud_account_id: type: string - include_engines: - type: array - items: - type: string - exclude_engines: - type: array - items: - type: string - include_regions: - type: array - items: - type: string - exclude_regions: - type: array - items: - type: string - include_types: - type: array - items: - type: string - exclude_types: - type: array - items: - type: string - min_count: - type: integer - minimum: 0 - description: | - Minimum instance/node count a recommendation must carry to be - surfaced (GUI/persisted equivalent of the CLI --min-count flag). - 0 (default) disables the filter. + format: uuid + description: Account created on approval. + reviewed_by: + type: string + description: Reviewing user ID, or admin-api-key when approved or rejected with the admin API key. + reviewed_at: + type: string + format: date-time + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time - CommitmentOptionsResponse: + RejectRegistrationRequest: type: object - required: [status] properties: - status: + reason: type: string - enum: [ok, unavailable] - description: | - `ok` means `aws` carries the probed combos. `unavailable` means - the probe hasn't run (no AWS account connected) or the most - recent attempt failed; the frontend should use hardcoded - defaults. - aws: - type: object - description: | - Per-service supported (term, payment) tuples. Keys are service - identifiers (rds, elasticache, opensearch, redshift, memorydb, - ec2). Omitted when status is `unavailable`. - additionalProperties: - type: array - items: - $ref: '#/components/schemas/CommitmentOptionCombo' + description: Shown to the registrant via GET /api/register/{token} and in the decision email. - CommitmentOptionCombo: + RejectRegistrationResponse: type: object - required: [term, payment] + required: [id, status, provider, external_id, account_name, rejection_reason] properties: - term: - type: integer - enum: [1, 3] - description: Commitment length in years. - payment: + id: type: string - enum: [all-upfront, partial-upfront, no-upfront] + format: uuid + status: + type: string + enum: [rejected] + provider: + type: string + external_id: + type: string + account_name: + type: string + rejection_reason: + type: string + description: Empty string when no reason was given. - # -- Dashboard ---------------------------------------------------------- - DashboardSummary: + # -- Cloud Accounts ----------------------------------------------------- + + FederationIaCResponse: type: object + required: [filename, content, content_type] properties: - potential_monthly_savings: - type: number - format: double - total_recommendations: - type: integer - active_commitments: - type: integer - committed_monthly: - type: number - format: double - current_coverage: - type: number - format: double - target_coverage: - type: number - format: double - ytd_savings: - type: number - format: double - by_service: - type: object - additionalProperties: - $ref: '#/components/schemas/ServiceSavings' + filename: + type: string + description: Suggested download file name (.sh or .zip). + content: + type: string + description: File content; base64-encoded when content_encoding is base64. + content_type: + type: string + enum: [text/x-shellscript, application/zip] + content_encoding: + type: string + enum: [base64] + description: Present (base64) for zip archives; omitted for the shell script. - ServiceSavings: + # -- Version ------------------------------------------------------------ + + VersionResponse: type: object + required: [version, git_sha, build_time] properties: - potential_savings: - type: number - format: double - current_savings: - type: number - format: double + version: + type: string + description: Build version, or dev when not stamped at build time. + git_sha: + type: string + description: Git commit SHA, or unknown when not stamped. + build_time: + type: string + description: Build timestamp, or unknown when not stamped. - UpcomingPurchaseResponse: + RecommendationDetailResponse: type: object + required: [id, usage_history, confidence_bucket, provenance_note] properties: - purchases: + id: + type: string + usage_history: type: array + description: > + Utilization history. Always empty for now: the collector does not + yet persist per-recommendation utilization. items: - $ref: '#/components/schemas/UpcomingPurchase' + $ref: '#/components/schemas/UsagePoint' + confidence_bucket: + type: string + enum: [low, medium, high] + description: > + high is at least 200 monthly savings and a count of at least 3, + medium is at least 50 monthly savings, low otherwise. + provenance_note: + type: string + description: > + Human-readable source of the recommendation, plus the last + collection time (RFC 3339, UTC) when it is known. + hidden_by: + type: array + description: > + Present when an account service override would hide this + recommendation from the list; names each failing dimension. + Omitted when the recommendation is visible. + items: + type: string + enum: [enabled=false, engine, region, resource_type] - UpcomingPurchase: + UsagePoint: type: object + required: [timestamp, cpu_pct, mem_pct] properties: - execution_id: - type: string - plan_name: - type: string - scheduled_date: - type: string - format: date - provider: - type: string - service: + timestamp: type: string - step_number: - type: integer - total_steps: - type: integer - estimated_savings: + cpu_pct: + type: number + format: double + mem_pct: type: number format: double - has_recommendations: - type: boolean - description: > - False for a plan step that carries no recommendations; - estimated_savings is then a placeholder, not data (platform#609). - # -- Recommendations ---------------------------------------------------- - RecommendationsResponse: + # -- Plan accounts ------------------------------------------------------ + + SetPlanAccountsRequest: type: object + required: [account_ids] properties: - recommendations: + account_ids: type: array + minItems: 1 + description: The plan's complete new account list (cloud account UUIDs). items: - $ref: '#/components/schemas/RecommendationRecord' - total_savings: - type: number - format: double - count: - type: integer + type: string + format: uuid - RecommendationRecord: + # -- Purchase retry ----------------------------------------------------- + + RetryPurchaseResponse: type: object + required: [execution_id, original_execution, status, recommendation_count, total_upfront_cost, estimated_savings, email_sent, retry_attempt_n] properties: - id: - type: string - provider: - type: string - service: - type: string - region: - type: string - resource_type: - type: string - engine: + execution_id: type: string - cloud_account_id: + format: uuid + description: The new execution. + original_execution: type: string - nullable: true - description: > - The cloud account this recommendation belongs to. Matched - against the stored recommendation set together with provider, - service, region, resource_type, engine, term and payment - (#1905); an account-scoped recommendation cannot be resolved - without it. Null or omitted only for recommendations that are - not tied to a specific account. - count: - type: integer - recommended_count: - type: integer - description: > - The pre-scaling count originally recommended, before any - Capacity % scaling was applied to produce count. Optional: - omitted or 0 means "not supplied" and the capacity-consistency - check is skipped for this recommendation. For a Savings Plan, - the stored value of this field is also the count that count - must equal, since Savings Plans are priced by hourly commitment - rather than by count. - term: - type: integer - payment: + format: uuid + description: The failed execution that was retried. + status: type: string - upfront_cost: + enum: [pending, failed] + description: failed when the approval email could not be sent (see email_reason). + recommendation_count: + type: integer + total_upfront_cost: type: number format: double - monthly_cost: + estimated_savings: type: number format: double - savings: + email_sent: + type: boolean + retry_attempt_n: + type: integer + description: Attempt number of the new execution in the retry chain. + email_reason: + type: string + description: Why the approval email was not sent. Omitted when it was sent. + approval_recipient: + type: string + description: Address the approval email went to. Omitted when none. + + # -- Purchase revoke ---------------------------------------------------- + + RevokePurchaseRequest: + type: object + description: > + The refund quote the user confirmed, from GET + /api/purchases/{id}/revoke/calculate. Both fields are required to + revoke an Azure reservation; other paths ignore the body. + properties: + expected_refund_amount: type: number format: double - selected: - type: boolean - purchased: + expected_refund_currency: + type: string + + RevokePurchaseResponse: + type: object + description: > + One of three shapes. A canceled scheduled execution: status + "canceled" and message. A revoked purchase history row: status + "revoked" or "already_revoked" with revoked_at and revoked_via. An + Azure refund that was issued but could not be recorded yet: code + RECONCILE_PENDING, azure_returned and message (do not retry). + properties: + status: + type: string + enum: [canceled, revoked, already_revoked] + message: + type: string + revoked_at: + type: string + format: date-time + revoked_via: + type: string + description: How the revocation was made, for example direct-api. + code: + type: string + enum: [RECONCILE_PENDING] + azure_returned: type: boolean - purchase_id: + + RevokeRefundQuote: + type: object + required: [refund_amount, refund_currency, quoted_at] + properties: + refund_amount: + type: number + format: double + refund_currency: type: string - error: + quoted_at: type: string + format: date-time + + # -- RI Marketplace ----------------------------------------------------- - CollectResult: + MarketplacePriceTier: type: object - description: Result of a recommendation collection run + required: [term_months, price] properties: - collected: + term_months: type: integer - errors: + format: int64 + minimum: 1 + description: Remaining months this tier covers; at most the RI's remaining months. + price: + type: number + format: double + description: > + One-time USD sale price per instance when term_months remain. Must + be positive. + + MarketplaceListRequest: + type: object + properties: + price_schedule: type: array + description: > + Optional. Each tier's price must be at least 5% of the per-instance + upfront residual prorated to its term. When omitted, a single tier + covering the remaining months is priced at 95% of the per-instance + prorated upfront residual. items: - type: string + $ref: '#/components/schemas/MarketplacePriceTier' - RecommendationsFreshness: + MarketplaceListResponse: type: object - required: [last_collected_at, last_collection_error, last_collection_started_at, last_collection_partial] + required: [listing_id, listing_state, price_schedule, aws_fee_percent] properties: - last_collected_at: - type: string - format: date-time - nullable: true - description: When recommendations were last persisted; null on a cold start. - last_collection_error: + listing_id: type: string - nullable: true - description: Error from the most recent collection when a provider failed; null otherwise. - last_collection_started_at: + listing_state: type: string - format: date-time - nullable: true - description: Set while a collection is in flight. - last_collection_partial: - nullable: true + price_schedule: + type: array description: > - Non-null when the most recent persisted collection kept rows from - at least one incomplete sweep. Cleared by the next collection whose - sweeps are all complete. Carries counts and reason categories only. - allOf: - - $ref: '#/components/schemas/RecommendationsCollectionPartial' + The schedule the listing was created with; on a retry of an + earlier attempt this is the stored schedule, not the request's. + items: + $ref: '#/components/schemas/MarketplacePriceTier' + aws_fee_percent: + type: number + description: AWS Marketplace transaction fee charged on proceeds (12). + note: + type: string - RecommendationsCollectionPartial: + UserPermissionsResponse: type: object - required: [providers] + required: [permissions, is_admin] properties: - providers: + permissions: type: array items: - $ref: '#/components/schemas/PartialProviderSweep' + type: object + required: [action, resource] + properties: + action: + type: string + resource: + type: string + is_admin: + type: boolean + description: True when permissions include `{admin, *}`. - PartialProviderSweep: + ResetPasswordStatusResponse: type: object - required: [provider, incomplete_accounts, attempted_scopes, failed_scopes, failed_details, reasons] + required: [state, flow] properties: - provider: + state: type: string - enum: [aws, azure, gcp] - incomplete_accounts: - type: integer - description: Accounts (or the ambient host identity) whose sweep was incomplete. - attempted_scopes: - type: integer + enum: [valid, expired, used] description: > - Scopes (Azure subscriptions, GCP projects) the incomplete sweeps - were asked to cover; 0 when the provider does not report it (AWS). - failed_scopes: - type: integer - description: Scopes that could not be queried. - failed_details: - type: integer - description: Individual recommendation details rejected inside answering scopes (AWS). - reasons: - type: object + `used` also covers tokens that were never issued and deactivated + accounts. + flow: + type: string + enum: [reset, invite] description: > - Failure count per reason category. Keys are drawn from a closed - set: permission_denied, throttled, timeout, canceled, not_found, - provider_unavailable, other. - additionalProperties: - type: integer - example: - permission_denied: 2 - throttled: 1 + `invite` for a not-yet-activated invited user; `reset` otherwise, + including every `used` token. - # -- Plans -------------------------------------------------------------- - PlansResponse: + MFASetupRequest: type: object + required: [password] properties: - plans: + password: + type: string + description: Base64-encoded current password + + MFASetupResponse: + type: object + required: [secret, provisioning_uri] + properties: + secret: + type: string + description: Pending TOTP secret (base32), stored server side + provisioning_uri: + type: string + description: otpauth:// URI for rendering as a QR code + + MFAEnableRequest: + type: object + required: [code] + properties: + code: + type: string + description: TOTP code from the authenticator + + MFADisableRequest: + type: object + required: [password] + properties: + password: + type: string + description: Base64-encoded current password + code: + type: string + description: > + TOTP code or unused recovery code. Required while MFA is enabled. + + MFARegenerateRequest: + type: object + required: [code] + properties: + code: + type: string + description: TOTP code (recovery codes are not accepted) + + MFARecoveryCodesResponse: + type: object + required: [recovery_codes] + properties: + recovery_codes: type: array + description: Plaintext recovery codes, returned only once items: - $ref: '#/components/schemas/PlanWithHealth' + type: string - PlanRequest: + # -- Analytics trends --------------------------------------------------- + + AnalyticsTrendsResponse: type: object - required: [name] properties: - name: + start: type: string - description: + format: date-time + end: type: string - enabled: - type: boolean - auto_purchase: - type: boolean - notification_days_before: + format: date-time + months: type: integer - max_step_commitment: - type: number - nullable: true - exclusiveMinimum: 0 - description: >- - Ceiling on the total commitment (upfront + monthly * term) one ramp - step may auto-purchase. Omit on create for no cap (auto-purchase of - steps with recommendations is then refused); omit on update to keep - the current cap. On update, explicit null clears a manual plan's - cap and is refused while auto_purchase is true. + description: Calendar months spanned by start..end (at least 1); the length of the monthly window. + monthly: + type: array + items: + $ref: '#/components/schemas/TrendsMonthlySummary' + by_provider: + type: array + items: + $ref: '#/components/schemas/TrendsProviderBreakdown' + by_service: + type: array + items: + $ref: '#/components/schemas/TrendsServiceBreakdown' + + TrendsMonthlySummary: + type: object + properties: + month: + type: string + format: date-time + cloud_account_id: + type: string + description: Omitted when the snapshot has no linked cloud account. + account_id: + type: string + description: External account ID recorded on the snapshot provider: type: string service: type: string - term: + total_savings: + type: number + format: double + avg_coverage: + type: number + format: double + description: Average coverage percentage. Omitted when coverage data is absent (not zero). + snapshot_count: type: integer - payment: + + TrendsProviderBreakdown: + type: object + properties: + provider: type: string - target_coverage: - type: integer - ramp_schedule: + service: type: string - enum: [immediate, weekly-25pct, monthly-10pct, custom] - custom_step_percent: - type: integer - custom_interval_days: - type: integer - target_accounts: + total_savings: + type: number + format: double + avg_coverage: + type: number + format: double + description: Omitted when coverage data is absent. + + TrendsServiceBreakdown: + type: object + properties: + service: + type: string + region: + type: string + total_savings: + type: number + format: double + avg_coverage: + type: number + format: double + description: Omitted when coverage data is absent. + + # -- Inventory ---------------------------------------------------------- + + InventoryCommitmentsResponse: + type: object + required: [commitments] + properties: + commitments: type: array - minItems: 1 - description: > - UUIDs of cloud accounts the plan will purchase for. Required on - POST /plans — a plan must be tied to at least one account. Sending - an empty array returns HTTP 400. items: - type: string - format: uuid + $ref: '#/components/schemas/InventoryCommitment' - PurchasePlan: + InventoryCommitment: type: object properties: id: type: string - format: uuid - name: + description: '`:`' + provider: + type: string + account_id: + type: string + account_name: + type: string + description: Omitted when the account name cannot be resolved. + service: + type: string + resource_type: + type: string + region: type: string - enabled: - type: boolean - auto_purchase: - type: boolean - notification_days_before: + count: type: integer - max_step_commitment: - type: number - nullable: true - description: Cap on the total commitment one ramp step may auto-purchase; null means auto-purchase of steps with recommendations is refused. - services: - type: object - additionalProperties: - $ref: '#/components/schemas/ServiceConfig' - ramp_schedule: - $ref: '#/components/schemas/RampSchedule' - created_at: + term_years: + type: integer + payment_option: type: string - format: date-time - updated_at: + start_date: type: string format: date-time - next_execution_date: + description: Purchase time + end_date: type: string format: date-time + description: Computed expiry + upfront_cost: + type: number + format: double nullable: true - last_execution_date: - type: string - format: date-time + monthly_cost: + type: number + format: double nullable: true - last_notification_sent: + description: Null when the provider reported no monthly breakdown. + estimated_savings: + type: number + format: double + status: type: string - format: date-time - nullable: true - - # PlanWithHealth wraps PurchasePlan with a read-time-only health-score - # badge (issue #340 follow-up): score/factors are computed from the - # plan's attributes and recent executions, never persisted, so they - # can't drift into the PurchasePlan schema above. - PlanWithHealth: - allOf: - - $ref: '#/components/schemas/PurchasePlan' - - type: object - properties: - health_score: - type: integer - nullable: true - minimum: 0 - maximum: 100 - description: > - 0-100 plan health score. Green >= 80, amber 50-79, red < 50. - Null when the score could not be computed because one of its - required inputs was unavailable: the plan's execution counts, - or the stuck-ramp report that ramp_blocked is derived from. - Every factor only ever subtracts, so a score computed without - one of them would read healthier than the plan is; the whole - score is withheld instead. Clients must render null as - "unknown" rather than substituting a default. - health_factors: - type: array - items: - $ref: '#/components/schemas/PlanHealthFactor' + enum: [active] - PlanHealthFactor: + CoverageBreakdownResponse: type: object - description: One penalty subtracted from a plan's health score, named so a bad score is actionable instead of opaque. + required: [providers] properties: - code: - type: string - description: > - Which check produced the penalty. The two execution-based codes - (failed_executions, canceled_executions) count only executions - that entered that state within the trailing execution-retention - window (30 days), not over all history, so they describe the - plan's recent behaviour rather than its lifetime record. - ramp_blocked is derived from the execution rows of the plan's next - ramp step, not time-windowed, and pre-empts stalled / - behind_schedule: it means the step cannot complete because one of - its executions (one per cloud account, once the step has fanned - out) failed with no retry in flight, as opposed to the plan merely - running late. The remaining codes are derived from the plan's - current attributes and ramp schedule. - enum: [overdue, failed_executions, canceled_executions, ramp_blocked, stalled, behind_schedule, disabled_midway] - penalty: - type: integer - description: Points subtracted from 100 by this factor. - note: - type: string - description: > - Human-readable reason for display in the badge tooltip. Free - text, and its wording may change; clients must branch on `code` - rather than parsing this. + providers: + type: array + description: One section each for aws, azure and gcp, in that order. + items: + $ref: '#/components/schemas/ProviderCoverageSection' - RampSchedule: + ProviderCoverageSection: type: object properties: - type: + provider: type: string - enum: [immediate, weekly, monthly, custom] - percent_per_step: + enum: [aws, azure, gcp] + services: + type: array + nullable: true + description: Null when the provider has no usage data. + items: + $ref: '#/components/schemas/CoverageServiceRow' + overall_coverage_pct: type: number format: double - step_interval_days: - type: integer - current_step: - type: integer - total_steps: - type: integer - start_date: - type: string - format: date-time + nullable: true + description: Null when there is no usage signal (not zero). - CreatePlannedPurchasesRequest: + CoverageServiceRow: type: object properties: - count: - type: integer - start_date: + service: type: string - format: date + covered_monthly: + type: number + format: double + nullable: true description: > - First purchase date (YYYY-MM-DD), interpreted as midnight UTC. - Must be after the current time, so the earliest accepted value - is tomorrow's date in UTC. + Monthly spend covered by active commitments (recurring plus + amortized upfront). Null when a commitment's upfront cost is + unknown. + on_demand_monthly: + type: number + format: double + description: Uncommitted monthly spend, from recommendation savings + coverage_pct: + type: number + format: double + nullable: true + description: covered / (covered + on_demand) * 100. Null when unknown or both are zero. - CreatePlannedPurchasesResponse: - type: object - properties: - created: - type: integer + # -- Ladder ------------------------------------------------------------- - # -- Purchases ---------------------------------------------------------- - ExecutePurchaseRequest: + LadderRampSchedule: type: object - required: [recommendations] + required: [steps] + description: > + At least one step; after_days strictly ascending; fractions in (0, 1] + summing to 1. properties: - recommendations: + steps: type: array - items: - $ref: '#/components/schemas/RecommendationRecord' minItems: 1 - maxItems: 1000 - description: > - Each item must match a recommendation in the current recommendation set on - (provider, cloud_account_id, service, region, resource_type, engine, term, - payment). Only count, recommended_count and selected are honoured; every other - field (including id, details, upfront_cost, monthly_cost, savings, - on_demand_cost, purchased, purchase_id and error) is replaced server-side from - the stored recommendation, with the cost fields scaled by count. The spend-cap - check, execution record and approval email use those replaced values. Savings - plan items must keep the recommended count. - - ExecutePurchaseResponse: + items: + type: object + required: [after_days, fraction] + properties: + after_days: + type: integer + minimum: 0 + description: Days after the run starts at which the tranche fires + fraction: + type: number + format: double + maximum: 1 + description: Share of the target allocation, greater than 0 + + LadderConfig: type: object properties: - execution_id: + id: type: string format: uuid - status: + cloud_account_id: type: string - recommendation_count: + format: uuid + provider: + type: string + enum: [aws, azure, gcp] + enabled: + type: boolean + mode: + type: string + enum: [email_approval, auto_approve] + cadence: + type: string + enum: [daily, weekly] + target_coverage: + type: number + format: double + buffer_fraction: + type: number + format: double + baseline_percentile: + type: number + format: double + lookback_days: type: integer - total_upfront_cost: + buffer_utilization_threshold: type: number format: double - estimated_savings: + max_hourly_commit_per_run: type: number format: double - message: + description: Omitted when there is no cap. + max_actions_per_run: + type: integer + ramp_schedule: + $ref: '#/components/schemas/LadderRampSchedule' + created_at: type: string + format: date-time + updated_at: + type: string + format: date-time - PurchaseExecution: + LadderConfigsResponse: type: object + required: [configs] properties: - execution_id: + configs: + type: array + items: + $ref: '#/components/schemas/LadderConfig' + + LadderConfigUpsertRequest: + type: object + additionalProperties: false + required: [cloud_account_id, provider, mode, cadence, ramp_schedule] + properties: + cloud_account_id: type: string format: uuid - plan_id: + provider: type: string - plan_name: + enum: [aws, azure, gcp] + description: Must match the cloud account's provider. + enabled: + type: boolean + default: false + mode: type: string - status: + enum: [email_approval, auto_approve] + cadence: type: string - # Migration 000089 (expand-contract rename, cancelled -> canceled): - # both spellings are valid during the rolling deploy window. New code - # writes "canceled" (US); rows written by old code retain "cancelled" - # until the CONTRACT migration (#1278) normalizes them, at which - # point the legacy entry can be removed from this enum. - enum: [pending, notified, approved, canceled, cancelled, completed, failed, running, paused] - step_number: + enum: [daily, weekly] + target_coverage: + type: number + format: double + maximum: 100 + default: 100 + description: Percentage, greater than 0. + buffer_fraction: + type: number + format: double + minimum: 0 + default: 0.1 + description: Less than 1. + baseline_percentile: + type: number + format: double + maximum: 50 + default: 5 + description: Greater than 0. + lookback_days: type: integer - scheduled_date: - type: string - format: date - total_upfront_cost: + minimum: 1 + default: 30 + description: At least 10 for aws. + buffer_utilization_threshold: type: number format: double - estimated_savings: + maximum: 100 + default: 90 + description: Percentage, greater than 0. + max_hourly_commit_per_run: type: number format: double - recommendations: - type: array - items: - $ref: '#/components/schemas/RecommendationRecord' - notification_sent: + nullable: true + minimum: 0.000001 + maximum: 1.0e+12 + description: > + Cap on the hourly commitment one run may buy, with at most 6 + decimal places. Omit or send null for no cap. + max_actions_per_run: + type: integer + minimum: 1 + maximum: 50 + default: 10 + ramp_schedule: + $ref: '#/components/schemas/LadderRampSchedule' + id: + type: string + description: Used as the row ID only when a new row is inserted; ignored on update. + created_at: type: string format: date-time - nullable: true - completed_at: + description: Accepted and ignored. + updated_at: type: string format: date-time - nullable: true - error: + description: Accepted and ignored. + + LadderTimelineCursor: + type: object + required: [created_at, id] + description: Pass as after_created_at and after_id to fetch the next page. + properties: + created_at: + type: string + format: date-time + id: type: string + format: uuid - PlannedPurchasesResponse: + LadderTimelinePage: type: object properties: - purchases: + events: type: array items: - $ref: '#/components/schemas/PlannedPurchase' + $ref: '#/components/schemas/LadderTimelineEvent' + next_cursor: + $ref: '#/components/schemas/LadderTimelineCursor' + total_count: + type: integer + format: int64 + description: Tranches across all pages + total_usd_hr: + type: string + description: Decimal sum of amount_usd_hr across all pages + total_od_usd_hr: + type: string + description: Decimal sum of od_usd_hr across all pages - PlannedPurchase: + LadderTimelineEvent: type: object properties: id: type: string format: uuid - plan_id: + run_id: type: string - plan_name: + format: uuid + config_id: type: string - scheduled_date: + format: uuid + layer_type: type: string - format: date - provider: + enum: [ec2-instance-sp, compute-sp, convertible-ri, azure-reservation, azure-savings-plan] + term: type: string - service: + enum: [1yr, 3yr] + payment_option: type: string - resource_type: + enum: [all-upfront, partial-upfront, no-upfront] + status: type: string - region: + enum: [scheduled, fired, completed, cancelled, failed] + run_status: type: string - count: - type: integer - term: + enum: [planned, awaiting_approval, approved, executing, completed, failed, cancelled, expired] + execution_id: + type: string + description: Omitted until the tranche has an execution. + revision: type: integer - payment: + format: int64 + description: Pass as expected_revision when amending. + amount_usd_hr: type: string - estimated_savings: - type: number - format: double - upfront_cost: - type: number - format: double - has_recommendations: - type: boolean - description: > - False for a plan step that carries no recommendations; count, - resource_type, upfront_cost and estimated_savings are then - placeholders, not data (platform#609). - status: + description: Decimal hourly amount in the basis given by amount_basis + amount_basis: type: string - step_number: - type: integer - total_steps: - type: integer - - RunPurchaseResponse: - type: object - properties: - execution_id: + enum: [on_demand_equivalent, commitment] + description: Omitted for older rows, which are treated as on_demand_equivalent. + commitment_rate: type: string - format: uuid - status: + description: Present when amount_basis is commitment. + od_usd_hr: type: string - message: + description: On-demand-equivalent hourly amount, 6 decimal places, rounded up + scheduled_date: type: string + format: date-time + created_at: + type: string + format: date-time - # -- History / Analytics ------------------------------------------------ - HistoryResponse: + LadderTimelineRunPage: type: object properties: - summary: - $ref: '#/components/schemas/HistorySummary' - purchases: + runs: type: array items: - $ref: '#/components/schemas/PurchaseHistoryRecord' - truncated: - type: boolean - description: >- - True when a fetch reached its row cap, so older rows (and possibly - pending approvals) may be missing from purchases and summary. May - be a false positive when exactly `limit` or `executions_limit` rows exist. - Also true when the actionable executions class (pending, approved, - running, ...) reached its own cap of 1000 rows. - limit: - type: integer - description: Row cap on completed purchase history rows for this request. - executions_limit: - type: integer - description: >- - Row cap on terminal executions (failed, expired, canceled, ...), - fixed and ignoring the limit parameter. Actionable executions use a - separate cap of 1000. - - HistorySummary: - type: object - properties: - total_purchases: + $ref: '#/components/schemas/LadderTimelineRun' + next_cursor: + $ref: '#/components/schemas/LadderTimelineCursor' + total_count: type: integer - total_upfront: - type: number - format: double - nullable: true - total_monthly_savings: - type: number - format: double - total_annual_savings: - type: number - format: double + format: int64 + description: Runs across all pages - PurchaseHistoryRecord: + LadderTimelineRun: type: object properties: - account_id: + id: type: string - purchase_id: + format: uuid + config_id: type: string - timestamp: + format: uuid + status: type: string - format: date-time - provider: + enum: [planned, awaiting_approval, approved, executing, completed, failed, cancelled, expired] + started_at: type: string - service: + format: date-time + created_at: type: string - region: + format: date-time + baseline_usd_hr: type: string - resource_type: + nullable: true + description: Decimal; null when not computed + target_usd_hr: type: string - count: - type: integer - term: - type: integer - payment: + nullable: true + description: Decimal; null when not computed + existing_usd_hr: type: string - upfront_cost: - type: number - format: double nullable: true - monthly_cost: - type: number - format: double - estimated_savings: - type: number - format: double - plan_id: + description: Decimal; null when not computed + gap_usd_hr: type: string - plan_name: + nullable: true + description: Decimal; null when not computed + total_hourly_commit: type: string - ramp_step: - type: integer + description: Decimal + actions: + type: array + items: + $ref: '#/components/schemas/LadderTimelineAction' - AnalyticsResponse: + LadderTimelineAction: type: object properties: - start: + action: type: string - format: date-time - end: + layer: type: string - format: date-time - interval: + rationale: type: string - summary: - $ref: '#/components/schemas/HistorySummary' - data_points: + data_sources: type: array items: - $ref: '#/components/schemas/HistoryDataPoint' + type: string - HistoryDataPoint: + LadderTrancheAmendment: type: object + additionalProperties: false + required: [expected_revision, scheduled_date, amount_usd_hr] properties: - timestamp: - type: string - format: date-time - total_savings: - type: number - format: double - total_upfront: - type: number - format: double - nullable: true - purchase_count: + expected_revision: type: integer - cumulative_savings: - type: number - format: double - by_service: - type: object - additionalProperties: - type: number - format: double - by_provider: - type: object - additionalProperties: - type: number - format: double - - BreakdownResponse: - type: object - properties: - dimension: - type: string - start: + format: int64 + minimum: 0 + description: The tranche's current revision + scheduled_date: type: string format: date-time - end: + description: Must be in the future. + amount_usd_hr: type: string - format: date-time - data: - type: object - additionalProperties: - $ref: '#/components/schemas/BreakdownValue' - - BreakdownValue: - type: object - properties: - total_savings: - type: number - format: double - total_upfront: - type: number - format: double - nullable: true - purchase_count: - type: integer - percentage: - type: number - format: double + pattern: '^(0|[1-9]\d{0,13})(\.\d{1,6})?$' + description: Positive decimal with at most 6 fractional digits - # -- Health ------------------------------------------------------------- - HealthResponse: + LadderTrancheAmendmentResult: type: object properties: - status: + id: type: string - enum: [healthy, degraded] - timestamp: + format: uuid + revision: + type: integer + format: int64 + scheduled_date: type: string format: date-time - checks: - type: object - additionalProperties: - $ref: '#/components/schemas/HealthCheck' - - HealthCheck: - type: object - properties: - status: + amount_usd_hr: type: string - enum: [healthy, unhealthy] - message: + run_total_usd_hr: type: string + description: The run's total hourly amount after the change - # -- Info --------------------------------------------------------------- - PublicInfoResponse: + LadderTrancheResolveRequest: type: object - description: > - Unauthenticated public info. Only safe-to-expose fields are included. - Sensitive identifiers (API key secret URL, AWS account ID) are served - by the authenticated /api/info/deployment endpoint (#633). + additionalProperties: false + required: [outcome, reason] + description: At most 4096 bytes. properties: - version: + outcome: type: string - admin_exists: + enum: [failed, completed] + description: Only failed is implemented; completed answers 501. + reason: + type: string + minLength: 10 + maxLength: 300 + description: Trimmed; no control characters. + note: + type: string + maxLength: 200 + description: Trimmed; no control characters. + acknowledge_unknown_outcome: type: boolean + description: Must be true exactly when cloud_check is sent. + cloud_check: + type: object + additionalProperties: false + required: [checked_account_id, checked_region, checked_service, checked_at] + description: Where the operator looked for the purchase. + properties: + checked_account_id: + type: string + pattern: '^[A-Za-z0-9_-]{1,64}$' + description: The tranche's cloud account ID or provider account number + checked_region: + type: string + pattern: '^[a-z0-9-]{1,32}$' + checked_service: + type: string + pattern: '^[a-z0-9-]{1,32}$' + description: Must equal the tranche's layer_type. + checked_at: + type: string + format: date-time + description: > + Must not be in the future and must follow the last execution + change; checked only when an execution's outcome is unknown. - DeploymentInfoResponse: + LadderTrancheResolveResponse: type: object - description: > - Deployment-scoped identifiers. Requires AuthUser. Not accessible to - unauthenticated callers (#633). properties: - api_key_secret_url: + id: type: string - description: AWS Console deep-link to the Secrets Manager secret holding the API key. - deployment_aws_account_id: + format: uuid + status: type: string - description: > - AWS account ID of the CUDly Lambda deployment (from STS GetCallerIdentity). - Present only on AWS-hosted deployments; omitted on Azure/GCP or when STS - is unreachable. Used by the frontend to distinguish legitimate ambient-credential - executions (cloud_account_id IS NULL because the rec targets this account) - from orphan rows whose account was deleted (issue #608). + enum: [failed] + status_reason: + type: string + replayed: + type: boolean + description: True when an identical earlier request already resolved it. diff --git a/internal/api/openapi_403_test.go b/internal/api/openapi_403_test.go index e1007c064..98dbfd08f 100644 --- a/internal/api/openapi_403_test.go +++ b/internal/api/openapi_403_test.go @@ -230,7 +230,10 @@ func handlerFuncCallsRequirePermission(srcDir, fnName string) (bool, error) { if inTarget { braceDepth += strings.Count(line, "{") - strings.Count(line, "}") - if strings.Contains(line, "requirePermission") { + // Ignore comments: a body that only mentions requirePermission + // in prose does not gate on a permission. + code, _, _ := strings.Cut(line, "//") + if strings.Contains(code, "requirePermission") { f.Close() return true, nil } @@ -248,33 +251,29 @@ func handlerFuncCallsRequirePermission(srcDir, fnName string) (bool, error) { return false, nil } -// specPathsForRoute returns the set of spec routeKeys that the given router -// entry could correspond to. -func specPathsForRoute(entry routeHandlerEntry, specOps map[string]bool) []string { +// specPathsForRoute returns the spec routeKeys that the router dispatches to +// entries[idx]. Each spec path is instantiated with a sample value per +// {param} and matched against all entries in declaration order with the +// router's own first-match rule, so a broad prefix route (for example +// GET /api/purchases/) does not claim operations that an earlier, more +// specific route serves (GET /api/purchases/revoke/{id}). +func specPathsForRoute(entries []routeHandlerEntry, idx int, specOps map[string]bool) []string { + r := &Router{} var matches []string - for key := range specOps { - parts := strings.SplitN(key, " ", 2) - if len(parts) != 2 { - continue - } - method, path := parts[0], parts[1] - - if entry.method != "" && method != strings.ToUpper(entry.method) { + method, path, ok := strings.Cut(key, " ") + if !ok { continue } - - if entry.exactPath != "" { - if path == entry.exactPath { - matches = append(matches, key) + concrete := specPathParam.ReplaceAllString(path, routeCoverageSample) + for i, e := range entries { + route := Route{ExactPath: e.exactPath, PathPrefix: e.pathPrefix, PathSuffix: e.pathSuffix, Method: e.method} + if r.matches(route, method, concrete) { + if i == idx { + matches = append(matches, key) + } + break } - continue - } - - matchesPrefix := entry.pathPrefix == "" || strings.HasPrefix(path, entry.pathPrefix) - matchesSuffix := entry.pathSuffix == "" || strings.HasSuffix(path, entry.pathSuffix) - if matchesPrefix && matchesSuffix { - matches = append(matches, key) } } return matches @@ -298,10 +297,14 @@ func TestOpenAPI403OnPermissionGatedRoutes(t *testing.T) { routerEntries, err := parseRouterEntries(routerPath) require.NoError(t, err, "failed to parse router.go") require.NotEmpty(t, routerEntries, "parsed zero route entries from router.go") + // specPathsForRoute replays first-match dispatch over routerEntries, so + // the source parse must see exactly the live route table, in order. + require.Len(t, routerEntries, len(newRouteCoverageRouter().routes), + "router.go parse and the live route table disagree; is a Route literal split across lines?") var failures []string - for _, entry := range routerEntries { + for i, entry := range routerEntries { if entry.delegateFnName == "" { // Could not resolve the delegate; skip rather than false-negative. continue @@ -318,7 +321,7 @@ func TestOpenAPI403OnPermissionGatedRoutes(t *testing.T) { } // Find the spec operations that match this route. - matched := specPathsForRoute(entry, specOps) + matched := specPathsForRoute(routerEntries, i, specOps) if len(matched) == 0 { // Route not in spec yet — out of scope for this test. continue diff --git a/internal/api/openapi_route_coverage_test.go b/internal/api/openapi_route_coverage_test.go new file mode 100644 index 000000000..24b3e5e28 --- /dev/null +++ b/internal/api/openapi_route_coverage_test.go @@ -0,0 +1,215 @@ +package api + +import ( + "regexp" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// Issue #262: openapi.yaml had drifted from router.go, leaving about a third +// of the routed paths undocumented. That hides them from API consumers and +// also exempts them from TestOpenAPI403OnPermissionGatedRoutes, which only +// knows the routes the spec lists. This guard keeps the two in lockstep in +// both directions: +// +// - every registered route must be reached by at least one documented +// operation, and +// - every documented operation must dispatch to a registered route. +// +// "Reached" is decided by the live router itself: each spec path is +// instantiated with a sample value per path parameter and handed to the same +// first-match logic Router.Route uses, so prefix/suffix ordering and +// multi-segment parameters behave exactly as they do in production. + +// routeCoverageSample stands in for every {param} segment of a spec path. +const routeCoverageSample = "x" + +// specOnlyOperations are documented operations deliberately served outside +// the API router. Each entry needs a reason. +var specOnlyOperations = map[string]string{ + "GET /ready": "served by internal/server/http.go before the API router (the Lambda transport has no readiness probe)", +} + +// undocumentedRoutes are registered routes whose documentation lands +// elsewhere. Keys use routeCoveragePattern. Each entry needs a reason, and an +// entry fails the test once the route is documented, so it cannot go stale. +var undocumentedRoutes = map[string]string{ + "GET /api/insurance/comparison": "documented by PR #1080", + "GET /api/insurance/status": "documented by PR #1080", +} + +var specPathParam = regexp.MustCompile(`\{[^/{}]+\}`) + +// routeCoveragePattern renders a route as "METHOD path", with "*" for the +// variable part of a prefix route and "ANY" for a method-less route. +func routeCoveragePattern(route Route) string { + method := route.Method + if method == "" { + method = "ANY" + } + if route.ExactPath != "" { + return method + " " + route.ExactPath + } + return method + " " + route.PathPrefix + "*" + route.PathSuffix +} + +// specOperationKeys returns "METHOD /path" for every operation in the spec. +func specOperationKeys(t *testing.T, spec []byte) []string { + t.Helper() + var doc struct { + Paths map[string]map[string]yaml.Node `yaml:"paths"` + } + require.NoError(t, yaml.Unmarshal(spec, &doc)) + require.NotEmpty(t, doc.Paths, "spec has no paths") + + methods := map[string]bool{ + "get": true, "put": true, "post": true, "delete": true, + "options": true, "head": true, "patch": true, "trace": true, + } + var keys []string + for path, item := range doc.Paths { + for method := range item { + if methods[method] { + keys = append(keys, strings.ToUpper(method)+" "+path) + } + } + } + sort.Strings(keys) + return keys +} + +// dispatchIndex returns the index of the route Router.Route would pick for +// method and path, or -1 when none matches. +func dispatchIndex(r *Router, method, path string) int { + for i, route := range r.routes { + if r.matches(route, method, path) { + return i + } + } + return -1 +} + +// routeCoverageGaps compares a router's routes with a spec's operations and +// returns the routes no operation reaches and the operations no route serves. +func routeCoverageGaps(t *testing.T, r *Router, spec []byte) (undocumented, unrouted []string) { + t.Helper() + covered := make([]bool, len(r.routes)) + for _, key := range specOperationKeys(t, spec) { + method, path, _ := strings.Cut(key, " ") + concrete := specPathParam.ReplaceAllString(path, routeCoverageSample) + idx := dispatchIndex(r, method, concrete) + if idx < 0 { + unrouted = append(unrouted, key) + continue + } + covered[idx] = true + } + for i, route := range r.routes { + if !covered[i] { + undocumented = append(undocumented, routeCoveragePattern(route)) + } + } + return undocumented, unrouted +} + +// partitionExempt splits found into entries missing from exempt (real gaps) +// and exempt entries that are absent from found (stale exemptions). +func partitionExempt(found []string, exempt map[string]string) (gaps, stale []string) { + seen := make(map[string]bool, len(found)) + for _, f := range found { + seen[f] = true + if _, ok := exempt[f]; !ok { + gaps = append(gaps, f) + } + } + for e := range exempt { + if !seen[e] { + stale = append(stale, e) + } + } + sort.Strings(stale) + return gaps, stale +} + +func newRouteCoverageRouter() *Router { + r := &Router{h: &Handler{}} + r.registerRoutes() + return r +} + +func TestOpenAPIDocumentsEveryRoute(t *testing.T) { + r := newRouteCoverageRouter() + require.NotEmpty(t, r.routes) + + undocumented, unrouted := routeCoverageGaps(t, r, openapiSpec) + + gaps, stale := partitionExempt(undocumented, undocumentedRoutes) + assert.Empty(t, gaps, + "routes registered in router.go with no operation in openapi.yaml; document them:\n%s", + strings.Join(gaps, "\n")) + assert.Empty(t, stale, + "undocumentedRoutes entries that are now documented (or no longer routed); delete them:\n%s", + strings.Join(stale, "\n")) + + gaps, stale = partitionExempt(unrouted, specOnlyOperations) + assert.Empty(t, gaps, + "operations in openapi.yaml that no route in router.go serves; fix the path or method, or remove them:\n%s", + strings.Join(gaps, "\n")) + assert.Empty(t, stale, + "specOnlyOperations entries that the router now serves (or the spec no longer lists); delete them:\n%s", + strings.Join(stale, "\n")) +} + +// The checker itself must notice drift in each direction; otherwise a bug in +// it would let TestOpenAPIDocumentsEveryRoute pass vacuously. +func TestRouteCoverageGapsDetectsDrift(t *testing.T) { + r := &Router{routes: []Route{ + {ExactPath: "/api/a", Method: "GET"}, + {PathPrefix: "/api/b/", PathSuffix: "/sub", Method: "POST"}, + {PathPrefix: "/api/b/", Method: "GET"}, + {ExactPath: "/health"}, + }} + spec := []byte(` +paths: + /api/a: + get: {} + post: {} + /api/b/{id}/sub: + post: {} + /health: + get: {} +`) + undocumented, unrouted := routeCoverageGaps(t, r, spec) + assert.Equal(t, []string{"GET /api/b/*"}, undocumented) + assert.Equal(t, []string{"POST /api/a"}, unrouted) + + // A multi-segment parameter reaches the prefix route, as it does live. + spec = []byte(` +paths: + /api/a: + get: {} + /api/b/{id}/sub: + post: {} + /api/b/{provider}/{service}: + get: {} + /health: + get: {} +`) + undocumented, unrouted = routeCoverageGaps(t, r, spec) + assert.Empty(t, undocumented) + assert.Empty(t, unrouted) +} + +func TestPartitionExemptReportsStaleEntries(t *testing.T) { + gaps, stale := partitionExempt( + []string{"GET /a", "GET /b"}, + map[string]string{"GET /b": "reason", "GET /gone": "reason"}, + ) + assert.Equal(t, []string{"GET /a"}, gaps) + assert.Equal(t, []string{"GET /gone"}, stale) +} From 2fe9db4911cb8e56a6b125e83a3c52ed58f9f8be Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Wed, 21 Oct 2026 09:38:19 +0100 Subject: [PATCH 2/2] fix(api): complete history response schemas and address #1100 review - PurchaseHistoryRecord now lists all 39 JSON fields the struct emits, including status, and marks monthly_cost nullable. HistorySummary lists all 10 fields (the per-state counters were missing). - GET /api/auth/me/permissions declares bearerAuth or apiKeyAuth, matching the handler, which accepts user and admin API keys. - The 403 guard skips only whole-line comments, so a "//" inside a string literal can no longer hide a requirePermission call on the same line. - Document the route-coverage guard's limits: server-mux routes are out of scope and path parameter names are not checked. Refs #262 Co-Authored-By: Claude Opus 5.5 --- internal/api/openapi.yaml | 102 ++++++++++++++++++++ internal/api/openapi_403_test.go | 10 +- internal/api/openapi_route_coverage_test.go | 11 +++ 3 files changed, 119 insertions(+), 4 deletions(-) diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 0658859c1..f9c10945c 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -2853,6 +2853,9 @@ paths: operationId: getCurrentUserPermissions tags: [Auth] summary: Get the caller's effective permissions + security: + - bearerAuth: [] + - apiKeyAuth: [] description: > For a session, the union of the user's group permissions. For a user API key, the key's permissions intersected with its owner's. For the @@ -6634,9 +6637,28 @@ components: HistorySummary: type: object + description: >- + total_purchases counts every row (completed and all other states); the + total_* counters break it down. Dollar totals count completed, + non-revoked rows only. properties: total_purchases: type: integer + total_completed: + type: integer + total_pending: + type: integer + description: Rows with status pending, notified or scheduled. + total_in_progress: + type: integer + description: Rows with status approved, running or paused. + total_failed: + type: integer + total_expired: + type: integer + total_revoked: + type: integer + description: Revoked commitments; excluded from total_completed. total_upfront: type: number format: double @@ -6650,11 +6672,16 @@ components: PurchaseHistoryRecord: type: object + description: >- + One completed purchase_history row or one synthesized execution row + (pending, failed, expired, ...). Fields marked as execution-only are + computed at read time and absent from completed rows. properties: account_id: type: string purchase_id: type: string + description: Provider purchase ID, or the execution ID on execution rows. timestamp: type: string format: date-time @@ -6679,6 +6706,10 @@ components: monthly_cost: type: number format: double + nullable: true + description: >- + Null when the provider returned no monthly recurring breakdown. + Aggregations skip nulls rather than counting them as 0. estimated_savings: type: number format: double @@ -6688,6 +6719,77 @@ components: type: string ramp_step: type: integer + cloud_account_id: + type: string + status: + type: string + description: >- + "completed" for purchase_history rows. Execution rows carry the + execution status: pending, notified, scheduled, approved, + running, paused, completed, partially_completed, failed, expired + or canceled (legacy rows may say "cancelled"). + approver: + type: string + description: Approval recipient email; pending and notified rows only. + source: + type: string + status_description: + type: string + description: Short explanation for non-completed rows. + created_by_user_id: + type: string + description: Execution-only. + created_by_system: + type: boolean + description: Execution-only. True for scheduler-created executions. + retry_execution_id: + type: string + description: Execution-only. Successor execution when this row was retried. + retry_attempt_n: + type: integer + description: Execution-only. Position in the retry chain (0 for the original). + ops_hint: + type: string + description: Execution-only. Operator hint shown instead of Retry on failed rows. + redrive_unsafe: + type: boolean + description: Execution-only. True when ops_hint is a permanent re-drive refusal. + is_audit_gap: + type: boolean + description: >- + Execution-only. A completed purchase whose history write failed; + its dollars are excluded from the summary totals. + created_by_user_email: + type: string + description: Execution-only. Email of the user who created the execution. + revocation_window_closes_at: + type: string + format: date-time + description: End of the provider's free-cancel window, when it has one. + revoked_at: + type: string + format: date-time + revoked_via: + type: string + description: direct-api or support-case. + support_case_id: + type: string + calc_refund_amount: + type: number + format: double + description: Refund amount quoted when the revoke was calculated. + calc_refund_currency: + type: string + revocation_in_flight: + type: boolean + offering_class: + type: string + description: standard or convertible (AWS RIs). + listing_id: + type: string + listing_state: + type: string + description: Marketplace listing state (active, pending, cancelled or closed). AnalyticsResponse: type: object diff --git a/internal/api/openapi_403_test.go b/internal/api/openapi_403_test.go index 98dbfd08f..5cfa6a278 100644 --- a/internal/api/openapi_403_test.go +++ b/internal/api/openapi_403_test.go @@ -230,10 +230,12 @@ func handlerFuncCallsRequirePermission(srcDir, fnName string) (bool, error) { if inTarget { braceDepth += strings.Count(line, "{") - strings.Count(line, "}") - // Ignore comments: a body that only mentions requirePermission - // in prose does not gate on a permission. - code, _, _ := strings.Cut(line, "//") - if strings.Contains(code, "requirePermission") { + // Ignore whole-line comments: a body that only mentions + // requirePermission in prose does not gate on a permission. + // Only whole lines are skipped, so a "//" inside a string + // literal (a URL) cannot hide a real call on the same line. + isComment := strings.HasPrefix(strings.TrimSpace(line), "//") + if !isComment && strings.Contains(line, "requirePermission") { f.Close() return true, nil } diff --git a/internal/api/openapi_route_coverage_test.go b/internal/api/openapi_route_coverage_test.go index 97a63ca07..4a43aef2f 100644 --- a/internal/api/openapi_route_coverage_test.go +++ b/internal/api/openapi_route_coverage_test.go @@ -25,6 +25,17 @@ import ( // instantiated with a sample value per path parameter and handed to the same // first-match logic Router.Route uses, so prefix/suffix ordering and // multi-segment parameters behave exactly as they do in production. +// +// Scope and limits: +// +// - Only routes from Router.registerRoutes are walked. Paths the server mux +// handles before the API router (internal/server/http.go, such as /oidc/* +// and /api/scheduled/*) are outside this guard. +// - Parameter names and segment counts under a prefix route are not +// checked. Every {param} becomes the same sample value and a prefix route +// accepts any remainder, so renaming {provider}/{service} to {service} +// still reaches the same route. The guard catches missing, extra and +// wrong-method operations, not misnamed parameters. // routeCoverageSample stands in for every {param} segment of a spec path. const routeCoverageSample = "x"