diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..2b9af7e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Report something the SDK gets wrong +title: "" +labels: "needs triage" +assignees: "" +--- + +#### What happened + +_What the SDK did._ + +#### What you expected + +_What you expected it to do instead._ + +#### Reproducing it + +_The smallest snippet that shows the problem. Please redact tokens._ + +```go +``` + +#### Versions + +- SDK version (or `firezone.Version`): +- Go version (`go version`): +- Firezone portal: cloud, or self-hosted at version: + +#### Anything else + +_Error text, the request that failed, whatever else is relevant._ diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d932aec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: "Community Support - Discussion Forums" + url: "https://discourse.firez.one" + about: "Ask questions, get help from other Firezone users, and suggest features." + - name: "Community Support - Discord" + url: "https://discord.gg/DY8gxpSgep" + about: "Join discussions, meet other users, and get updates from the Firezone team." + - name: "Report a security vulnerability" + url: "https://github.com/firezone/firezone-go/security/advisories/new" + about: "Please report vulnerabilities privately, not as a public issue." diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a6797aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: Feature request +about: Suggest something the SDK should do +title: "" +labels: "needs triage" +assignees: "" +--- + +#### What would you like the SDK to do? + +_A clear description of the behavior you want._ + +#### What are you trying to accomplish? + +_The underlying goal. This often suggests a better shape for the API +than the one that first comes to mind._ + +#### Is there an API endpoint behind it? + +_If this is about an endpoint the SDK doesn't cover yet, say which one. +Some are deliberately out of scope; see the README._ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..96ac8ec --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +#### What does this change? + +_A short description, and the issue it closes if there is one._ + +#### Why? + +_What problem this solves. If it fixes a bug, what the bug was._ + +#### Checklist + +- [ ] `mise run check` passes +- [ ] `mise run test-acceptance` passes, if this touches the wire +- [ ] `mise run spec-check` passes, if this adds or changes a JSON field +- [ ] `CHANGELOG.md` updated under `Unreleased`, if this is user-visible +- [ ] Exported identifiers have doc comments diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbe4977..8b30494 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,13 @@ jobs: - name: Unit tests (-race) run: mise run test + # The only check that can catch a struct tag the server doesn't + # actually send. It runs against the spec vendored in testdata/, + # so it needs no monorepo checkout for now. In the future the + # spec will be retrieved from a live source. + - name: Check struct tags against the OpenAPI spec + run: mise run spec-check + - name: Scan for known vulnerabilities (govulncheck) run: mise run vuln diff --git a/.gitignore b/.gitignore index 178135c..e2a92bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ +.env +.envrc /dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..efef033 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +All notable changes to this project are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Because this is a client library, "breaking" means a change that stops +existing code compiling or changes what a call does. A new exported +field or method is additive, thus not breaking. + +While this project is at 0.x the exported API is not yet frozen: +breaking changes bump the minor version and are listed under +`### Changed`, with a note on what to do about them. From 1.0.0 onward +they will require a new major version. + +## [Unreleased] + +## [0.1.0] - 2026-09-02 + +First public release. The API is complete and verified against a live +Firezone portal, but ships as 0.x while it gets real use. See the +versioning note above for what that means for compatibility. + +### Added + +- Read-write services for Sites, Resources, Policies, Groups, Actors and + Client devices, with Gateways nested under Sites, memberships nested + under Groups, and pool members nested under Resources. +- Read-only services for the five auth provider types and the three + directory connection types. +- Cursor pagination on every list method (`ListOptions` → `Page[T]`). +- Typed errors: `APIError` carrying RFC 9457 problem details, with + `IsNotFound`, `IsValidation`, `IsRateLimited`, `IsForbidden`, + `IsUnauthorized` and `IsConflict` predicates. +- Automatic retry with exponential backoff and jitter on HTTP 429, + honouring `Retry-After`. Configurable via `WithRetry` and + `WithRetryMaxWait`. +- `Null[T]` with `Set` and `Clear`, so nullable fields on merge-patch + update requests can be cleared as well as set. +- `Version`, sent as part of the default `User-Agent`. + +### Notes + +- `Update` methods send `PATCH`. The API routes `PATCH` and `PUT` to the + same handler, but a partial update is what `PATCH` means, and sending + the matching verb insures against the two ever diverging. + `Memberships.ReplaceAll` and `PoolMembers.ReplaceAll` send `PUT`, + where the API genuinely distinguishes them, as do `Verify` and + `Unverify`. +- Some endpoints are deliberately not wrapped; the README lists which, + and the `GatewaysService` doc comment explains the Gateway token ones + in particular. + +[Unreleased]: https://github.com/firezone/firezone-go/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/firezone/firezone-go/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0e7103e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,106 @@ +# Contributing + +Thanks for your interest in improving the Firezone Go SDK. + +## Getting set up + +Tool versions come from `.tool-versions`, so local runs and CI can't +drift apart. With [mise](https://mise.jdx.dev) installed: + +```bash +mise install +mise run check # everything CI runs, in one shot +``` + +The SDK has no third-party dependencies — standard library only. Please +keep it that way; a dependency in a client library becomes a dependency +for everyone who imports it. + +## Before opening a pull request + +```bash +mise run check +``` + +That runs formatting, `go vet` (including the `integration` and `spec` +build tags), `golangci-lint`, unit tests with `-race`, the OpenAPI spec +checks, `govulncheck`, and a build. Individual tasks are listed by +`mise tasks`. + +Two more that CI doesn't run for you: + +```bash +mise run test-floor # build + test on the oldest supported Go +mise run test-acceptance # against a real portal - see the README +``` + +Run the acceptance tests after changing anything that touches the wire. +They are the only tests that can disagree with the SDK about how the +server behaves, and they have caught real bugs that every other check +passed. + +## Conventions worth knowing + +**Every exported identifier needs a doc comment.** `golangci-lint` +enforces it. This package is meant to be read through `go doc` and +pkg.go.dev. + +**JSON struct tags are checked against the OpenAPI spec.** A tag the +server doesn't recognise fails silently at runtime — a response field +decodes as a zero value, and a request field is dropped by the server +without an error. `mise run spec-check` compares the tags against the +spec vendored at `testdata/openapi.json`. Run it whenever you add or +change a field. + +**Nullable fields have rules in both directions.** On a merge-patch +update request, a field the spec marks nullable must be `*Null[T]` so it +can be cleared as well as set, and an array field must be `*[]T` so an +empty list can be sent. On a read model, a nullable non-string field +must be a pointer, because `0` and `false` are legitimate values that a +null would be indistinguishable from. Both rules are enforced by the +spec tests. + +**Request paths go through `buildPath`, never string concatenation**, +and caller-supplied IDs go through `checkID` first. + +**The `go` directive in `go.mod` is the oldest Go this SDK supports**, +not the version we build with. Keep it as low as the code allows — +raising it is a hard requirement on every consumer. + +## Updating the vendored OpenAPI spec + +```bash +FIREZONE_MONOREPO=/path/to/firezone mise run spec-update +``` + +The resulting diff is the list of API changes this SDK hasn't accounted +for yet, so it belongs in the same pull request as the code that +responds to it — never as a standalone commit to make CI green. + +## Cutting a release + +1. Refresh the vendored spec (`mise run spec-update`) and deal with any + diff. Shipping against a stale spec means shipping unverified tags. +2. Update `Version` in `firezone.go`. It is sent in the `User-Agent`, so + it needs to match the tag. +3. Move the `Unreleased` section of `CHANGELOG.md` into a new version + section and update the comparison links at the bottom. +4. `mise run check`, `mise run test-floor`, and `mise run test-acceptance`. + The acceptance run is not optional for a release: it is the only + check that can disagree with the SDK about how the server behaves. +5. Tag `vX.Y.Z` and push the tag. pkg.go.dev picks it up from there; + there are no artifacts to build. + +## Versioning + +Semantic versioning applies to the exported API. Retyping an existing +exported field or changing what a call does is breaking, even when it is +a bug fix. + +**This project is at 0.x.** The exported API is not frozen yet: a +breaking change bumps the minor version (0.1.0 → 0.2.0) and is called +out in the changelog. That is deliberate. The SDK is verified against a +live portal, but it has not yet been through enough sustained real use. + +Once the library has seen enough real use and any adjustments have been made +we will move to 1.0.0 diff --git a/README.md b/README.md index 4399ff6..8cd4e10 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,14 @@ -# firezone (api-client) +# Go Firezone + +[![Go Reference](https://pkg.go.dev/badge/github.com/firezone/firezone-go.svg)](https://pkg.go.dev/github.com/firezone/firezone-go) +[![Go Report Card](https://goreportcard.com/badge/github.com/firezone/firezone-go)](https://goreportcard.com/report/github.com/firezone/firezone-go) A Go client for the Firezone REST API. +```bash +go get github.com/firezone/firezone-go +``` + ```go import firezone "github.com/firezone/firezone-go" @@ -12,8 +19,9 @@ if err != nil { site, err := client.Sites.Create(ctx, &firezone.CreateSiteRequest{Name: "primary-dc"}) if err != nil { - if firezone.IsConflict(err) { - // a site with that name already exists + if firezone.IsValidation(err) { + // e.g. a Site with that name already exists - the API reports a + // duplicate name as a 422 with a field-level error, not a 409 } return err } @@ -22,29 +30,131 @@ gw, err := client.Sites.Gateways(site.ID).Provision(ctx, &firezone.ProvisionGate Name: "gw-nyc-1", }) // gw.Token is only ever returned here, on Provision - the API never -// re-exposes it. See the Gateway type's doc comment before storing it -// anywhere long-lived. +// re-exposes it. See the ProvisionedGateway type's doc comment before +// storing it anywhere long-lived. ``` `baseURL` passed to `NewClient` is always the bare API host -(`https://api.firezone.dev`). +(`https://api.firezone.dev`). It must carry an `http`/`https` scheme and +a host, and no query or fragment — `NewClient` rejects anything else +rather than letting it surface later as a confusing transport error. + +Resource IDs are validated and percent-escaped before they reach the +URL, so an ID that arrived from config or upstream data can never +redirect a call to a different endpoint. An empty ID fails with +`ErrMissingID` before any request is sent: + +```go +_, err := client.Sites.Get(ctx, siteID) +if errors.Is(err, firezone.ErrMissingID) { + // siteID was never populated +} +``` + +## Versioning + +This SDK is at 0.x: the exported API is not frozen, and a breaking +change bumps the minor version. It is verified against a live Firezone +portal and the surface is deliberate rather than provisional, but it has +not yet had sustained real use. Pin a version in `go.mod` — as Go does +by default — and read [CHANGELOG.md](CHANGELOG.md) before upgrading. + +## Requirements + +Go 1.22 or newer. The SDK has no third-party dependencies — standard +library only. CI builds against both the current Go release and the +1.22 floor, so the minimum is tested rather than assumed. ## Resources -`Client` exposes one service per resource: +`Client` exposes one service per resource. These are read-write: + * `Sites` * `Resources` * `Policies` * `Groups` * `Actors` -* `Gateways` nested under `Sites` (`client.Sites.Gateways(siteID)`) - -This matches the API's own URL nesting. +* `ClientDevices` — Client devices. Named for `ClientDevice`, since + `Clients` reads as the SDK's own client type. + +These are read-only (list and get only): + +* `EmailOTPAuthProviders`, `OIDCAuthProviders`, `GoogleAuthProviders`, + `EntraAuthProviders`, `OktaAuthProviders` +* `EntraDirectories`, `GoogleDirectories`, `OktaDirectories` + +Some API endpoints are deliberately not covered: `/account`, `/logs`, +actor client tokens and external identities, the posture provider and +managed device endpoints (Defender, Intune, IRU, Santa, SentinelOne), +and `/x509_auth_provider`. Open an issue if you need one. + +The Gateway token endpoints are a considered omission rather than a gap. +A Gateway has at most one active token, and the whole of its life is +covered: `Gateways(siteID).Provision` creates the Gateway and mints its +token together, `RotateToken` replaces it, and `Delete` destroys the +Gateway and revokes it. What is left out: + +* `POST /sites/{site_id}/gateways/{gateway_id}/token` — creates a token + for a Gateway that has none. Every Gateway created through this SDK + already has one, so this would always return 409; the API directs you + to rotate instead. It is only useful for adopting a Gateway created in + the admin portal. +* `POST /sites/{site_id}/gateway_tokens` — a multi-owner token shared by + all of a Site's Gateways, which the API marks deprecated. +* The `DELETE` endpoints under `/sites/{site_id}/gateway_tokens` — + deleting the Gateway revokes its token, which covers every case except + a Gateway stranded past a rotation grace period. Recover from that by + deleting and re-provisioning the Gateway. + +Three services are nested under a parent, matching the API's own URL +nesting: + +* `client.Sites.Gateways(siteID)` +* `client.Groups.Memberships(groupID)` +* `client.Resources.PoolMembers(resourceID)` Every list method takes `*ListOptions{Limit, PageCursor}` and returns a `*Page[T]{Data, Metadata}`. See the resource file for each type's exact fields (`sites.go`, `resources.go`, `policies.go`, `groups.go`, -`memberships.go`, `actors.go`, `gateways.go`). +`memberships.go`, `actors.go`, `gateways.go`, `clients.go`, +`auth_providers.go`, `directories.go`, `pool_members.go`). + +## Updating nullable fields + +The update endpoints are merge-patch: a field absent from the request +body keeps its current value, and an explicit JSON `null` clears it. +Fields the API allows to be null are typed `*Null[T]` so both are +reachable: + +```go +_, err := client.Resources.Update(ctx, resourceID, &firezone.UpdateResourceRequest{ + Name: "postgres-prod", // set + AddressDescription: firezone.Clear[string](), // -> null, clears it + SiteID: firezone.Set(siteID), // -> "site-..." + // Address omitted entirely -> left untouched +}) +``` + +`Set("")` clears a nullable string field too: the API treats an empty +string as an empty value and replaces it with the field's default, which +for a nullable field is null. Prefer `Clear` regardless — it states the +intent, works for non-string types, and doesn't rely on that behavior. + +The embedded lists (`UpdatePolicyRequest.Conditions`, +`UpdateResourceRequest.Filters`) are `*[]T` for the same reason: `nil` +leaves them alone, and a pointer to an empty slice removes all of them. + +```go +_, err := client.Policies.Update(ctx, policyID, &firezone.UpdatePolicyRequest{ + Conditions: &[]firezone.Condition{}, // remove every condition +}) +``` + +`Create*Request` needs none of this — omitting an optional field on +create already leaves it null. + +A `Client` is safe for concurrent use by multiple goroutines; it holds +no mutable state after construction. ## Errors @@ -56,6 +166,8 @@ codes directly: switch { case firezone.IsNotFound(err): case firezone.IsConflict(err): + // rare: the API reports most conflicts, including duplicate names, + // as 422 validation errors rather than 409 case firezone.IsValidation(err): // err.(*firezone.APIError).ValidationErrors has field-level detail case firezone.IsRateLimited(err): @@ -67,8 +179,10 @@ case firezone.IsUnauthorized(err): ## Retries Requests are retried automatically on HTTP 429 with exponential -backoff, honoring the API's `Retry-After` header (5 attempts by -default). Disable or tune this via `firezone.WithRetry`: +backoff, honoring the API's `Retry-After` header (10 attempts by +default). Only 429 is retried — network errors and 5xx responses are +returned to the caller, since neither is safe to assume idempotent. +Disable or tune this via `firezone.WithRetry`: ```go client, _ := firezone.NewClient(endpoint, token, firezone.WithRetry(false, 0)) @@ -77,15 +191,60 @@ client, _ := firezone.NewClient(endpoint, token, firezone.WithRetry(false, 0)) ## Testing ```bash +mise run check # everything CI runs, in one shot mise run test # unit tests, no server needed (httptest-based) +mise run test-floor # build + test on the oldest supported Go +mise run spec-check # struct tags vs the vendored OpenAPI spec +mise run vuln # govulncheck mise run test-acceptance # requires FIREZONE_ENDPOINT/FIREZONE_TOKEN ``` -To get a token for the acceptance tests, boot `mix phx.server` in a -`firezone/firezone` checkout and run that repo's -`elixir/script/seed_api_client_token.exs` - see the +The acceptance tests run against a real portal. A plain `go test ./...` +never touches the network: they are behind a build tag and skip unless +`FIREZONE_ENDPOINT` and `FIREZONE_TOKEN` are both set. `mise run +test-acceptance` refuses to run at all without them, rather than +skipping — Go reports an all-skipped package as `ok`, which is +indistinguishable from a real pass. They +are not part of CI — CI only type-checks them — so run them locally +after changing anything that touches the wire. They create real objects, +each named `gosdk--…` and removed on cleanup. + +To run them against a local dev server, boot the portal in a +`firezone/firezone` checkout and mint a token: + +```bash +# terminal 1 - the portal +cd /path/to/firezone/elixir && mix phx.server + +# terminal 2 - mint a token and run the tests +cd /path/to/firezone/elixir +token=$(MIX_ENV=dev mix run --no-start script/seed_api_client_token.exs | tail -1) + +cd /path/to/firezone-go +export FIREZONE_ENDPOINT=https://localhost:13001 +export FIREZONE_TOKEN="$token" +export FIREZONE_CA_CERT=/path/to/firezone/elixir/priv/cert/selfsigned.pem +mise run test-acceptance +``` + +The dev API listens on **HTTPS** (port 13001 by default, overridable via +`PHOENIX_API_PORT`) with a self-signed certificate, so `FIREZONE_CA_CERT` +is needed unless that certificate is already in your machine's trust +store. The seed script creates a fresh throwaway account and an +`api_client` actor on every run and prints the bearer token as its last +line; the token is valid for a day. + +Because the account is fresh, the tests covering objects the API cannot +create — Client devices and static device pools — will skip, and the +auth provider and directory tests will report zero records. That is +expected. See the [`terraform-provider-firezone`](https://github.com/firezone/terraform-provider-firezone) -README's "Local development" section for the full sequence. +README's "Local development" section for more on the dev environment. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). To report a security issue, see +[SECURITY.md](SECURITY.md). ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..720f8f8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Reporting a vulnerability + +Please do **not** report security vulnerabilities through public GitHub +issues, discussions, or pull requests. + +Report them through GitHub's private vulnerability reporting instead: +go to this repository's **Security** tab and choose **Report a +vulnerability**. That opens a private advisory visible only to the +maintainers. + +Please include: + +- the version of this SDK, and the Go version you are building with +- what an attacker could do with the issue +- the smallest set of steps that reproduces it +- any proof-of-concept code, if you have it + +## Scope + +This repository is the Go client library for the Firezone REST API. If you +believe you've found a vulnerability in the Firezone product itself +(e.g. the portal, the Gateway, or a Client) please file the bug in the +[firezone/firezone](https://github.com/firezone/firezone) repository +rather than here. diff --git a/actors.go b/actors.go index 086aedd..4cfcf86 100644 --- a/actors.go +++ b/actors.go @@ -65,11 +65,13 @@ type CreateActorRequest struct { // it names the Actor behind the calling token - an Actor cannot // disable itself. type UpdateActorRequest struct { - Name string `json:"name,omitempty"` - Type ActorType `json:"type,omitempty"` - Email string `json:"email,omitempty"` - AllowEmailOTPSignIn *bool `json:"allow_email_otp_sign_in,omitempty"` - IsDisabled *bool `json:"is_disabled,omitempty"` + Name string `json:"name,omitempty"` + Type ActorType `json:"type,omitempty"` + // Email is nullable, so it is typed [Null] - Clear[string]() removes + // the Actor's email, and a nil pointer leaves it alone. + Email *Null[string] `json:"email,omitempty"` + AllowEmailOTPSignIn *bool `json:"allow_email_otp_sign_in,omitempty"` + IsDisabled *bool `json:"is_disabled,omitempty"` } // ActorsService manages Actors. @@ -79,8 +81,11 @@ type ActorsService struct { // Get fetches a single Actor by ID. func (s *ActorsService) Get(ctx context.Context, id string) (*Actor, error) { + if err := checkID("Actor ID", id); err != nil { + return nil, err + } var actor Actor - if err := s.client.do(ctx, "GET", "actors/"+id, nil, nil, &actor); err != nil { + if err := s.client.do(ctx, "GET", buildPath("actors", id), nil, nil, &actor); err != nil { return nil, err } return &actor, nil @@ -128,12 +133,15 @@ func (s *ActorsService) Create(ctx context.Context, req *CreateActorRequest) (*A // Update updates an Actor. func (s *ActorsService) Update(ctx context.Context, id string, req *UpdateActorRequest) (*Actor, error) { + if err := checkID("Actor ID", id); err != nil { + return nil, err + } body, err := wrapBody("actor", req) if err != nil { return nil, err } var actor Actor - if err := s.client.do(ctx, "PUT", "actors/"+id, nil, body, &actor); err != nil { + if err := s.client.do(ctx, "PATCH", buildPath("actors", id), nil, body, &actor); err != nil { return nil, err } return &actor, nil @@ -141,7 +149,10 @@ func (s *ActorsService) Update(ctx context.Context, id string, req *UpdateActorR // Delete deletes an Actor. func (s *ActorsService) Delete(ctx context.Context, id string) error { - return s.client.do(ctx, "DELETE", "actors/"+id, nil, nil, nil) + if err := checkID("Actor ID", id); err != nil { + return err + } + return s.client.do(ctx, "DELETE", buildPath("actors", id), nil, nil, nil) } // Disable disables an Actor, immediately revoking all of its active @@ -151,6 +162,9 @@ func (s *ActorsService) Delete(ctx context.Context, id string) error { // This is a convenience wrapper over [ActorsService.Update]; the API // has no dedicated disable endpoint. func (s *ActorsService) Disable(ctx context.Context, id string) (*Actor, error) { + if err := checkID("Actor ID", id); err != nil { + return nil, err + } disabled := true return s.Update(ctx, id, &UpdateActorRequest{IsDisabled: &disabled}) } @@ -161,6 +175,9 @@ func (s *ActorsService) Disable(ctx context.Context, id string) (*Actor, error) // This is a convenience wrapper over [ActorsService.Update]; the API // has no dedicated enable endpoint. func (s *ActorsService) Enable(ctx context.Context, id string) (*Actor, error) { + if err := checkID("Actor ID", id); err != nil { + return nil, err + } disabled := false return s.Update(ctx, id, &UpdateActorRequest{IsDisabled: &disabled}) } diff --git a/actors_test.go b/actors_test.go index 0d596a2..88ed4c8 100644 --- a/actors_test.go +++ b/actors_test.go @@ -25,8 +25,8 @@ func TestActorsService_DisableEnable(t *testing.T) { if err != nil { t.Fatalf("Disable returned error: %v", err) } - if gotMethod != http.MethodPut || gotPath != "/actors/actor-1" { - t.Errorf("request = %s %s, want PUT /actors/actor-1", gotMethod, gotPath) + if gotMethod != http.MethodPatch || gotPath != "/actors/actor-1" { + t.Errorf("request = %s %s, want PATCH /actors/actor-1", gotMethod, gotPath) } reqActor, ok := gotBody["actor"].(map[string]any) @@ -61,8 +61,8 @@ func TestActorsService_DisableEnable(t *testing.T) { if err != nil { t.Fatalf("Enable returned error: %v", err) } - if gotMethod != http.MethodPut || gotPath != "/actors/actor-1" { - t.Errorf("request = %s %s, want PUT /actors/actor-1", gotMethod, gotPath) + if gotMethod != http.MethodPatch || gotPath != "/actors/actor-1" { + t.Errorf("request = %s %s, want PATCH /actors/actor-1", gotMethod, gotPath) } reqActor, ok := gotBody["actor"].(map[string]any) diff --git a/auth_providers.go b/auth_providers.go index eaab4d0..3701148 100644 --- a/auth_providers.go +++ b/auth_providers.go @@ -22,8 +22,19 @@ type AuthProvider struct { // ClientSessionLifetimeSecs and PortalSessionLifetimeSecs are how // long a sign-in through this provider stays valid on a Client // device and in the admin portal respectively. - ClientSessionLifetimeSecs int `json:"client_session_lifetime_secs"` - PortalSessionLifetimeSecs int `json:"portal_session_lifetime_secs"` + // + // Both are nil when the provider sets no override and Firezone's own + // defaults apply, which is the usual case - the columns have no + // database default, so a provider that has never had them configured + // stores null. They are pointers for that reason: a plain int would + // decode null to 0, which reads as "sessions expire immediately" + // rather than "not configured". + // + // The spec marks these nullable only on the Entra provider, but the + // underlying schema is identical for all five, so treat every one of + // them as nullable. + ClientSessionLifetimeSecs *int `json:"client_session_lifetime_secs,omitempty"` + PortalSessionLifetimeSecs *int `json:"portal_session_lifetime_secs,omitempty"` IsDisabled bool `json:"is_disabled"` @@ -86,8 +97,11 @@ type EmailOTPAuthProvidersService struct { // Get fetches a single Email OTP auth provider by ID. func (s *EmailOTPAuthProvidersService) Get(ctx context.Context, id string) (*EmailOTPAuthProvider, error) { + if err := checkID("auth provider ID", id); err != nil { + return nil, err + } var p EmailOTPAuthProvider - if err := s.client.do(ctx, "GET", "email_otp_auth_providers/"+id, nil, nil, &p); err != nil { + if err := s.client.do(ctx, "GET", buildPath("email_otp_auth_providers", id), nil, nil, &p); err != nil { return nil, err } return &p, nil @@ -107,8 +121,11 @@ type OIDCAuthProvidersService struct { // Get fetches a single OIDC auth provider by ID. func (s *OIDCAuthProvidersService) Get(ctx context.Context, id string) (*OIDCAuthProvider, error) { + if err := checkID("auth provider ID", id); err != nil { + return nil, err + } var p OIDCAuthProvider - if err := s.client.do(ctx, "GET", "oidc_auth_providers/"+id, nil, nil, &p); err != nil { + if err := s.client.do(ctx, "GET", buildPath("oidc_auth_providers", id), nil, nil, &p); err != nil { return nil, err } return &p, nil @@ -128,8 +145,11 @@ type GoogleAuthProvidersService struct { // Get fetches a single Google auth provider by ID. func (s *GoogleAuthProvidersService) Get(ctx context.Context, id string) (*GoogleAuthProvider, error) { + if err := checkID("auth provider ID", id); err != nil { + return nil, err + } var p GoogleAuthProvider - if err := s.client.do(ctx, "GET", "google_auth_providers/"+id, nil, nil, &p); err != nil { + if err := s.client.do(ctx, "GET", buildPath("google_auth_providers", id), nil, nil, &p); err != nil { return nil, err } return &p, nil @@ -149,8 +169,11 @@ type EntraAuthProvidersService struct { // Get fetches a single Entra auth provider by ID. func (s *EntraAuthProvidersService) Get(ctx context.Context, id string) (*EntraAuthProvider, error) { + if err := checkID("auth provider ID", id); err != nil { + return nil, err + } var p EntraAuthProvider - if err := s.client.do(ctx, "GET", "entra_auth_providers/"+id, nil, nil, &p); err != nil { + if err := s.client.do(ctx, "GET", buildPath("entra_auth_providers", id), nil, nil, &p); err != nil { return nil, err } return &p, nil @@ -170,8 +193,11 @@ type OktaAuthProvidersService struct { // Get fetches a single Okta auth provider by ID. func (s *OktaAuthProvidersService) Get(ctx context.Context, id string) (*OktaAuthProvider, error) { + if err := checkID("auth provider ID", id); err != nil { + return nil, err + } var p OktaAuthProvider - if err := s.client.do(ctx, "GET", "okta_auth_providers/"+id, nil, nil, &p); err != nil { + if err := s.client.do(ctx, "GET", buildPath("okta_auth_providers", id), nil, nil, &p); err != nil { return nil, err } return &p, nil diff --git a/clients.go b/clients.go index 912934d..0fc3d94 100644 --- a/clients.go +++ b/clients.go @@ -42,9 +42,11 @@ type ClientDevice struct { } // UpdateClientRequest is the request body for [ClientsService.Update]. -// Name is the only mutable field. +// Name is the only mutable field, and the API requires it, so it is +// always sent - omitting it on an empty value would produce a body the +// API rejects for a reason that doesn't name the field. type UpdateClientRequest struct { - Name string `json:"name,omitempty"` + Name string `json:"name"` } // ClientsService manages Clients. @@ -54,8 +56,11 @@ type ClientsService struct { // Get fetches a single Client by ID. func (s *ClientsService) Get(ctx context.Context, id string) (*ClientDevice, error) { + if err := checkID("Client ID", id); err != nil { + return nil, err + } var c ClientDevice - if err := s.client.do(ctx, "GET", "clients/"+id, nil, nil, &c); err != nil { + if err := s.client.do(ctx, "GET", buildPath("clients", id), nil, nil, &c); err != nil { return nil, err } return &c, nil @@ -88,12 +93,15 @@ func (s *ClientsService) List(ctx context.Context, opts *ClientListOptions) (*Pa // Update renames a Client. func (s *ClientsService) Update(ctx context.Context, id string, req *UpdateClientRequest) (*ClientDevice, error) { + if err := checkID("Client ID", id); err != nil { + return nil, err + } body, err := wrapBody("client", req) if err != nil { return nil, err } var c ClientDevice - if err := s.client.do(ctx, "PUT", "clients/"+id, nil, body, &c); err != nil { + if err := s.client.do(ctx, "PATCH", buildPath("clients", id), nil, body, &c); err != nil { return nil, err } return &c, nil @@ -101,14 +109,20 @@ func (s *ClientsService) Update(ctx context.Context, id string, req *UpdateClien // Delete deletes a Client, unenrolling the device. func (s *ClientsService) Delete(ctx context.Context, id string) error { - return s.client.do(ctx, "DELETE", "clients/"+id, nil, nil, nil) + if err := checkID("Client ID", id); err != nil { + return err + } + return s.client.do(ctx, "DELETE", buildPath("clients", id), nil, nil, nil) } // Verify marks a Client as admin-verified, satisfying the // client_verified Policy condition. func (s *ClientsService) Verify(ctx context.Context, id string) (*ClientDevice, error) { + if err := checkID("Client ID", id); err != nil { + return nil, err + } var c ClientDevice - if err := s.client.do(ctx, "PUT", "clients/"+id+"/verify", nil, nil, &c); err != nil { + if err := s.client.do(ctx, "PUT", buildPath("clients", id, "verify"), nil, nil, &c); err != nil { return nil, err } return &c, nil @@ -116,8 +130,11 @@ func (s *ClientsService) Verify(ctx context.Context, id string) (*ClientDevice, // Unverify clears a Client's verification. func (s *ClientsService) Unverify(ctx context.Context, id string) (*ClientDevice, error) { + if err := checkID("Client ID", id); err != nil { + return nil, err + } var c ClientDevice - if err := s.client.do(ctx, "PUT", "clients/"+id+"/unverify", nil, nil, &c); err != nil { + if err := s.client.do(ctx, "PUT", buildPath("clients", id, "unverify"), nil, nil, &c); err != nil { return nil, err } return &c, nil diff --git a/clients_test.go b/clients_test.go index e0e6db8..bea9e42 100644 --- a/clients_test.go +++ b/clients_test.go @@ -124,8 +124,8 @@ func TestClientsService_Update(t *testing.T) { t.Fatalf("Update returned error: %v", err) } - if gotMethod != http.MethodPut || gotPath != "/clients/client-1" { - t.Errorf("request = %s %s, want PUT /clients/client-1", gotMethod, gotPath) + if gotMethod != http.MethodPatch || gotPath != "/clients/client-1" { + t.Errorf("request = %s %s, want PATCH /clients/client-1", gotMethod, gotPath) } reqClient, ok := gotBody["client"].(map[string]any) if !ok || reqClient["name"] != "renamed" { diff --git a/directories.go b/directories.go index 1de6cf7..9421e81 100644 --- a/directories.go +++ b/directories.go @@ -10,20 +10,19 @@ import ( // are read-only via this API - they're managed through the Firezone // dashboard's identity provider setup, not created or updated here. type EntraDirectory struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - Name string `json:"name"` - TenantID string `json:"tenant_id"` - ErrorEmailCount int `json:"error_email_count"` - IsDisabled bool `json:"is_disabled"` - DisabledReason string `json:"disabled_reason,omitempty"` - SyncedAt *time.Time `json:"synced_at,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - ErroredAt *time.Time `json:"errored_at,omitempty"` - EmailField string `json:"email_field"` - SyncAllGroups bool `json:"sync_all_groups"` - InsertedAt time.Time `json:"inserted_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + AccountID string `json:"account_id"` + Name string `json:"name"` + TenantID string `json:"tenant_id"` + IsDisabled bool `json:"is_disabled"` + DisabledReason string `json:"disabled_reason,omitempty"` + SyncedAt *time.Time `json:"synced_at,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ErroredAt *time.Time `json:"errored_at,omitempty"` + EmailField string `json:"email_field"` + SyncAllGroups bool `json:"sync_all_groups"` + InsertedAt time.Time `json:"inserted_at"` + UpdatedAt time.Time `json:"updated_at"` } // GoogleDirectory is a Google Workspace directory connection. Directories @@ -35,7 +34,6 @@ type GoogleDirectory struct { Name string `json:"name"` Domain string `json:"domain"` ImpersonationEmail string `json:"impersonation_email"` - ErrorEmailCount int `json:"error_email_count"` IsDisabled bool `json:"is_disabled"` DisabledReason string `json:"disabled_reason,omitempty"` SyncedAt *time.Time `json:"synced_at,omitempty"` @@ -51,20 +49,19 @@ type GoogleDirectory struct { // read-only via this API - they're managed through the Firezone // dashboard's identity provider setup, not created or updated here. type OktaDirectory struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - Name string `json:"name"` - ClientID string `json:"client_id"` - Kid string `json:"kid"` - OktaDomain string `json:"okta_domain"` - ErrorEmailCount int `json:"error_email_count"` - IsDisabled bool `json:"is_disabled"` - DisabledReason string `json:"disabled_reason,omitempty"` - SyncedAt *time.Time `json:"synced_at,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - ErroredAt *time.Time `json:"errored_at,omitempty"` - InsertedAt time.Time `json:"inserted_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + AccountID string `json:"account_id"` + Name string `json:"name"` + ClientID string `json:"client_id"` + Kid string `json:"kid"` + OktaDomain string `json:"okta_domain"` + IsDisabled bool `json:"is_disabled"` + DisabledReason string `json:"disabled_reason,omitempty"` + SyncedAt *time.Time `json:"synced_at,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + ErroredAt *time.Time `json:"errored_at,omitempty"` + InsertedAt time.Time `json:"inserted_at"` + UpdatedAt time.Time `json:"updated_at"` } // EntraDirectoriesService reads Entra directory connections. Read-only: @@ -75,8 +72,11 @@ type EntraDirectoriesService struct { // Get fetches a single Entra directory by ID. func (s *EntraDirectoriesService) Get(ctx context.Context, id string) (*EntraDirectory, error) { + if err := checkID("directory ID", id); err != nil { + return nil, err + } var d EntraDirectory - if err := s.client.do(ctx, "GET", "entra_directories/"+id, nil, nil, &d); err != nil { + if err := s.client.do(ctx, "GET", buildPath("entra_directories", id), nil, nil, &d); err != nil { return nil, err } return &d, nil @@ -106,8 +106,11 @@ type GoogleDirectoriesService struct { // Get fetches a single Google Workspace directory by ID. func (s *GoogleDirectoriesService) Get(ctx context.Context, id string) (*GoogleDirectory, error) { + if err := checkID("directory ID", id); err != nil { + return nil, err + } var d GoogleDirectory - if err := s.client.do(ctx, "GET", "google_directories/"+id, nil, nil, &d); err != nil { + if err := s.client.do(ctx, "GET", buildPath("google_directories", id), nil, nil, &d); err != nil { return nil, err } return &d, nil @@ -127,8 +130,11 @@ type OktaDirectoriesService struct { // Get fetches a single Okta directory by ID. func (s *OktaDirectoriesService) Get(ctx context.Context, id string) (*OktaDirectory, error) { + if err := checkID("directory ID", id); err != nil { + return nil, err + } var d OktaDirectory - if err := s.client.do(ctx, "GET", "okta_directories/"+id, nil, nil, &d); err != nil { + if err := s.client.do(ctx, "GET", buildPath("okta_directories", id), nil, nil, &d); err != nil { return nil, err } return &d, nil diff --git a/errors.go b/errors.go index cdb8376..8fa092f 100644 --- a/errors.go +++ b/errors.go @@ -109,6 +109,12 @@ func parseRetryAfter(header string) time.Duration { func IsNotFound(err error) bool { return hasStatus(err, http.StatusNotFound) } // IsConflict reports whether err is an *APIError with StatusCode 409. +// +// The API returns 409 from a single endpoint - creating a token for a +// Gateway that already has one - which this SDK does not wrap, for the +// reasons in the [GatewaysService] doc comment. Most things that read +// like conflicts, a duplicate name among them, come back as 422 +// validation errors instead, so reach for [IsValidation] first. func IsConflict(err error) bool { return hasStatus(err, http.StatusConflict) } // IsValidation reports whether err is an *APIError with StatusCode 422. diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..7555afc --- /dev/null +++ b/example_test.go @@ -0,0 +1,151 @@ +package firezone_test + +import ( + "context" + "errors" + "fmt" + "log" + "os" + + firezone "github.com/firezone/firezone-go" +) + +func ExampleNewClient() { + client, err := firezone.NewClient("https://api.firezone.dev", os.Getenv("FIREZONE_TOKEN")) + if err != nil { + // Only a malformed base URL gets here - it must carry an + // http/https scheme and a host, and no query or fragment. + log.Fatal(err) + } + + site, err := client.Sites.Create(context.Background(), &firezone.CreateSiteRequest{ + Name: "primary-dc", + }) + if err != nil { + log.Fatal(err) + } + fmt.Println(site.Name) +} + +// The API reports a duplicate name as a validation error rather than a +// conflict, with the offending field named in ValidationErrors. +func ExampleIsValidation() { + var client *firezone.Client // see [NewClient] + + _, err := client.Sites.Create(context.Background(), &firezone.CreateSiteRequest{Name: "primary-dc"}) + if firezone.IsValidation(err) { + var apiErr *firezone.APIError + if errors.As(err, &apiErr) { + for field, messages := range apiErr.ValidationErrors { + fmt.Printf("%s: %v\n", field, messages) + } + } + } +} + +// Paging is an explicit cursor loop: keep requesting until the metadata +// stops handing back a next-page cursor. +func ExampleListOptions() { + var client *firezone.Client // see [NewClient] + + opts := &firezone.ResourceListOptions{ + ListOptions: firezone.ListOptions{Limit: 100}, + } + for { + page, err := client.Resources.List(context.Background(), opts) + if err != nil { + log.Fatal(err) + } + for _, resource := range page.Data { + fmt.Println(resource.Name) + } + if page.Metadata.NextPage == "" { + break + } + opts.PageCursor = page.Metadata.NextPage + } +} + +// Update requests are merge-patch: a field left nil keeps its current +// value, so this renames a Resource without disturbing anything else. +func ExampleResourcesService_Update() { + var client *firezone.Client // see [NewClient] + + updated, err := client.Resources.Update(context.Background(), "resource-id", + &firezone.UpdateResourceRequest{Name: "postgres-prod"}) + if err != nil { + log.Fatal(err) + } + fmt.Println(updated.Name) +} + +// Clear removes a nullable field, which no plain string value can +// express: a nil pointer means "leave it alone", so there would +// otherwise be no way to say "set this to nothing". +func ExampleClear() { + var client *firezone.Client // see [NewClient] + + _, err := client.Resources.Update(context.Background(), "resource-id", + &firezone.UpdateResourceRequest{ + // Remove the description; leave every other field untouched. + AddressDescription: firezone.Clear[string](), + // Move the Resource to another Site. + SiteID: firezone.Set("site-id"), + }) + if err != nil { + log.Fatal(err) + } +} + +// The embedded lists are pointers for the same reason: a nil pointer +// leaves them alone, and a pointer to an empty slice removes them all. +func ExampleUpdatePolicyRequest_conditions() { + var client *firezone.Client // see [NewClient] + + // Restrict the Policy to two countries. + _, err := client.Policies.Update(context.Background(), "policy-id", + &firezone.UpdatePolicyRequest{ + Conditions: &[]firezone.Condition{{ + Property: firezone.ConditionPropertyRemoteIPLocationRegion, + Operator: firezone.ConditionOperatorIsIn, + Values: []string{"US", "CA"}, + }}, + }) + if err != nil { + log.Fatal(err) + } + + // Remove every condition, granting access unconditionally. + if _, err := client.Policies.Update(context.Background(), "policy-id", + &firezone.UpdatePolicyRequest{Conditions: &[]firezone.Condition{}}); err != nil { + log.Fatal(err) + } +} + +// Gateways are nested under their Site, matching the API's own URLs. +// The token comes back exactly once, on provisioning. +func ExampleSitesService_Gateways() { + var client *firezone.Client // see [NewClient] + + gateway, err := client.Sites.Gateways("site-id").Provision(context.Background(), + &firezone.ProvisionGatewayRequest{Name: "gw-nyc-1"}) + if err != nil { + log.Fatal(err) + } + + // Store this now - the API never exposes it again. + fmt.Println(gateway.Token) +} + +// Retries are on by default and cover HTTP 429 only, honouring the +// API's Retry-After header. Tune the budget when a large concurrent run +// exhausts it. +func ExampleWithRetry() { + client, err := firezone.NewClient("https://api.firezone.dev", os.Getenv("FIREZONE_TOKEN"), + firezone.WithRetry(true, 20), + ) + if err != nil { + log.Fatal(err) + } + _ = client +} diff --git a/firezone.go b/firezone.go index 3516140..cb0b161 100644 --- a/firezone.go +++ b/firezone.go @@ -12,6 +12,13 @@ // no path prefix of any kind. (URL path versioning was tried and rolled // back before shipping; if it returns, it'll live in exactly one place // here rather than every call site.) +// +// A [Client] is safe for concurrent use by multiple goroutines. +// +// Update requests are merge-patch: a field left at its zero value is +// omitted and keeps its current value on the server. Fields the API +// allows to be null are typed [Null] so they can be cleared as well as +// set - see [Clear] and [Set]. package firezone import ( @@ -22,7 +29,7 @@ import ( "io" "net/http" "net/url" - "path" + "runtime" "strconv" "time" ) @@ -40,7 +47,25 @@ func (b requestBody) reader() io.Reader { return bytes.NewReader(b) } -const defaultUserAgent = "firezone-go-client" +// Version is this SDK's released version, following semantic +// versioning. It is sent as part of the default User-Agent, so a +// Firezone operator can tell which client version a request came from. +// +// It is a constant rather than something read from build info, because +// build info reports "(devel)" whenever the module is built rather than +// consumed. Bump it as part of cutting a release - see CONTRIBUTING.md. +const Version = "0.1.0" + +// defaultUserAgent identifies this SDK and its version, plus the Go +// runtime it was built with - the latter is worth having when a +// server-side problem turns out to be specific to one Go release's +// TLS or HTTP behavior. It carries no OS or architecture, which would +// narrow a request to a machine without helping diagnose anything. +// +// Override it wholesale with [WithUserAgent]; a caller that wants to +// identify itself while keeping this information can build a string +// from [Version]. +var defaultUserAgent = fmt.Sprintf("firezone-go-client/%s (%s)", Version, runtime.Version()) // String returns a pointer to s. Useful for optional string fields // (e.g. [GroupListOptions.DirectoryID]) where a plain string's zero @@ -50,6 +75,10 @@ func String(s string) *string { } // Client is a Firezone REST API client. +// +// A Client is safe for concurrent use by multiple goroutines: it holds +// no mutable state once [NewClient] returns, and each request builds its +// own URL and body. type Client struct { baseURL *url.URL token string @@ -104,7 +133,11 @@ func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } } -// WithUserAgent sets the User-Agent header sent with every request. +// WithUserAgent replaces the User-Agent header sent with every request. +// It replaces rather than extends the default, so a caller that wants to +// keep the SDK's identity should include it: +// +// firezone.WithUserAgent("terraform-provider-firezone/2.1.0 firezone-go-client/" + firezone.Version) func WithUserAgent(ua string) Option { return func(c *Client) { c.userAgent = ua } } @@ -146,13 +179,41 @@ func WithRetryMaxWait(d time.Duration) Option { return func(c *Client) { c.retryMaxWait = d } } +// checkBaseURL rejects a base URL that url.Parse accepts but that can +// never produce a working request. +// +// url.Parse is permissive: it reads "api.firezone.dev" as a relative +// path with no scheme and no host, and returns no error. Left alone, +// that surfaces much later as "unsupported protocol scheme" from the +// first API call, pointing at the request rather than at the typo in +// the configuration that caused it. +func checkBaseURL(raw string, u *url.URL) error { + switch { + case u.Scheme != "http" && u.Scheme != "https": + return fmt.Errorf("firezone: invalid base URL %q: scheme must be http or https, got %q", raw, u.Scheme) + case u.Host == "": + return fmt.Errorf("firezone: invalid base URL %q: missing host", raw) + // A query or fragment on the base URL is silently dropped on any + // request that sets its own query, so it would apply to some calls + // and not others. Reject it rather than half-honor it. + case u.RawQuery != "" || u.ForceQuery: + return fmt.Errorf("firezone: invalid base URL %q: must not include a query string", raw) + case u.Fragment != "": + return fmt.Errorf("firezone: invalid base URL %q: must not include a fragment", raw) + } + return nil +} + // NewClient constructs a Firezone API client. baseURL is the bare API // host (e.g. "https://api.firezone.dev") - do not include a version // segment. token is the Bearer token for an api_client actor. func NewClient(baseURL, token string, opts ...Option) (*Client, error) { parsed, err := url.Parse(baseURL) if err != nil { - return nil, fmt.Errorf("firezone: invalid base URL: %w", err) + return nil, fmt.Errorf("firezone: invalid base URL %q: %w", baseURL, err) + } + if err := checkBaseURL(baseURL, parsed); err != nil { + return nil, err } c := &Client{ @@ -217,9 +278,15 @@ type listEnvelope[T any] struct { // retry logic - callers needing retry-on-429 behavior use requestWithRetry. // The returned body is the raw response bytes; a non-2xx status yields a // non-nil *APIError. +// +// requestPath must already be escaped, which is what [buildPath] returns. +// Every call site builds its path that way, so a caller-supplied ID can +// never introduce a path separator. func (c *Client) rawRequest(ctx context.Context, method, requestPath string, query url.Values, body requestBody) ([]byte, error) { - u := *c.baseURL - u.Path = path.Join(u.Path, requestPath) + u, err := resolvePath(c.baseURL, requestPath) + if err != nil { + return nil, err + } if query != nil { u.RawQuery = query.Encode() } diff --git a/gateways.go b/gateways.go index c439230..3c3f080 100644 --- a/gateways.go +++ b/gateways.go @@ -83,19 +83,57 @@ type UpdateGatewayRequest struct { // GatewaysService manages the Gateways belonging to a single Site. // Obtain one via [SitesService.Gateways]. +// +// # Token lifecycle +// +// A Gateway has at most one active token, and this service covers the +// whole of that token's life: [GatewaysService.Provision] creates the +// Gateway and mints its token together, [GatewaysService.RotateToken] +// replaces it, and [GatewaysService.Delete] destroys the Gateway and +// revokes it. +// +// Two API endpoints are deliberately left out of that set: +// +// - POST /sites/{site_id}/gateways/{gateway_id}/token creates a token +// for a Gateway that has none. Every Gateway this SDK creates goes +// through Provision, which already mints one, so calling it would +// always fail with 409 Conflict - the API allows only one active +// token per Gateway and directs callers to rotate instead. The +// endpoint is only useful for a Gateway created elsewhere, such as +// in the admin portal. +// - POST /sites/{site_id}/gateway_tokens creates a multi-owner token +// shared by all of a Site's Gateways. The API marks it deprecated in +// favour of the per-Gateway endpoint above. +// +// The DELETE counterparts under /sites/{site_id}/gateway_tokens are +// absent for the same reason: a token this SDK can create belongs to a +// Gateway, and deleting that Gateway revokes it. The one case this +// leaves uncovered is a Gateway stranded past a rotation grace period +// (see [Gateway.RotationPending]), which is recovered by deleting and +// re-provisioning the Gateway rather than by deleting its token. +// +// If you need to adopt Gateways created outside this SDK, the token +// endpoints above are the gap to fill - adding them is additive, and +// [IsConflict] is already here for the 409 the first one returns. type GatewaysService struct { client *Client siteID string } -func (s *GatewaysService) basePath() string { - return "sites/" + s.siteID + "/gateways" +func (s *GatewaysService) basePath(segments ...string) string { + return buildPath(append([]string{"sites", s.siteID, "gateways"}, segments...)...) } // Get fetches a single Gateway by ID. func (s *GatewaysService) Get(ctx context.Context, id string) (*Gateway, error) { + if err := checkID("site ID", s.siteID); err != nil { + return nil, err + } + if err := checkID("Gateway ID", id); err != nil { + return nil, err + } var gateway Gateway - if err := s.client.do(ctx, "GET", s.basePath()+"/"+id, nil, nil, &gateway); err != nil { + if err := s.client.do(ctx, "GET", s.basePath(id), nil, nil, &gateway); err != nil { return nil, err } return &gateway, nil @@ -118,6 +156,9 @@ type GatewayListOptions struct { // List returns a page of the Site's Gateways. Pass nil for opts to use // the API's default page size and no filters. func (s *GatewaysService) List(ctx context.Context, opts *GatewayListOptions) (*Page[Gateway], error) { + if err := checkID("site ID", s.siteID); err != nil { + return nil, err + } if opts == nil { opts = &GatewayListOptions{} } @@ -132,6 +173,9 @@ func (s *GatewaysService) List(ctx context.Context, opts *GatewayListOptions) (* // Provision creates a new Gateway and mints its single-owner token in // one call. The returned Token is shown once - store it securely. func (s *GatewaysService) Provision(ctx context.Context, req *ProvisionGatewayRequest) (*ProvisionedGateway, error) { + if err := checkID("site ID", s.siteID); err != nil { + return nil, err + } body, err := wrapBody("gateway", req) if err != nil { return nil, err @@ -145,12 +189,18 @@ func (s *GatewaysService) Provision(ctx context.Context, req *ProvisionGatewayRe // Update renames a Gateway. func (s *GatewaysService) Update(ctx context.Context, id string, req *UpdateGatewayRequest) (*Gateway, error) { + if err := checkID("site ID", s.siteID); err != nil { + return nil, err + } + if err := checkID("Gateway ID", id); err != nil { + return nil, err + } body, err := wrapBody("gateway", req) if err != nil { return nil, err } var gateway Gateway - if err := s.client.do(ctx, "PUT", s.basePath()+"/"+id, nil, body, &gateway); err != nil { + if err := s.client.do(ctx, "PATCH", s.basePath(id), nil, body, &gateway); err != nil { return nil, err } return &gateway, nil @@ -177,14 +227,28 @@ func (s *GatewaysService) Update(ctx context.Context, id string, req *UpdateGate // replaces only that pending token; the in-use one keeps its original // deadline. func (s *GatewaysService) RotateToken(ctx context.Context, id string) (*RotatedGatewayToken, error) { + if err := checkID("site ID", s.siteID); err != nil { + return nil, err + } + if err := checkID("Gateway ID", id); err != nil { + return nil, err + } var rotated RotatedGatewayToken - if err := s.client.do(ctx, "POST", s.basePath()+"/"+id+"/token/rotate", nil, nil, &rotated); err != nil { + if err := s.client.do(ctx, "POST", s.basePath(id, "token", "rotate"), nil, nil, &rotated); err != nil { return nil, err } return &rotated, nil } -// Delete deletes a Gateway, revoking its token. +// Delete deletes a Gateway, revoking its token. This is the only way +// this SDK revokes a token: see the [GatewaysService] doc comment for +// why the API's standalone token-deletion endpoints are not wrapped. func (s *GatewaysService) Delete(ctx context.Context, id string) error { - return s.client.do(ctx, "DELETE", s.basePath()+"/"+id, nil, nil, nil) + if err := checkID("site ID", s.siteID); err != nil { + return err + } + if err := checkID("Gateway ID", id); err != nil { + return err + } + return s.client.do(ctx, "DELETE", s.basePath(id), nil, nil, nil) } diff --git a/gateways_test.go b/gateways_test.go index 82b3c65..db50cc9 100644 --- a/gateways_test.go +++ b/gateways_test.go @@ -101,8 +101,8 @@ func TestGatewaysService_Update_Rename(t *testing.T) { if err != nil { t.Fatalf("Update returned error: %v", err) } - if gotMethod != http.MethodPut || gotPath != "/sites/site-1/gateways/gw-1" { - t.Errorf("request = %s %s, want PUT /sites/site-1/gateways/gw-1", gotMethod, gotPath) + if gotMethod != http.MethodPatch || gotPath != "/sites/site-1/gateways/gw-1" { + t.Errorf("request = %s %s, want PATCH /sites/site-1/gateways/gw-1", gotMethod, gotPath) } if gateway.Name != "renamed" { t.Errorf("gateway.Name = %q, want renamed", gateway.Name) diff --git a/groups.go b/groups.go index f60aede..63fb10b 100644 --- a/groups.go +++ b/groups.go @@ -49,8 +49,11 @@ func (s *GroupsService) Memberships(groupID string) *MembershipsService { // Get fetches a single Group by ID. func (s *GroupsService) Get(ctx context.Context, id string) (*Group, error) { + if err := checkID("Group ID", id); err != nil { + return nil, err + } var group Group - if err := s.client.do(ctx, "GET", "groups/"+id, nil, nil, &group); err != nil { + if err := s.client.do(ctx, "GET", buildPath("groups", id), nil, nil, &group); err != nil { return nil, err } return &group, nil @@ -106,12 +109,15 @@ func (s *GroupsService) Create(ctx context.Context, req *CreateGroupRequest) (*G // Update updates a Group. Returns 403 Forbidden if the Group is synced // from an identity provider (see [Group.IsSynced]). func (s *GroupsService) Update(ctx context.Context, id string, req *UpdateGroupRequest) (*Group, error) { + if err := checkID("Group ID", id); err != nil { + return nil, err + } body, err := wrapBody("group", req) if err != nil { return nil, err } var group Group - if err := s.client.do(ctx, "PUT", "groups/"+id, nil, body, &group); err != nil { + if err := s.client.do(ctx, "PATCH", buildPath("groups", id), nil, body, &group); err != nil { return nil, err } return &group, nil @@ -120,5 +126,8 @@ func (s *GroupsService) Update(ctx context.Context, id string, req *UpdateGroupR // Delete deletes a Group. Returns 403 Forbidden if the Group is synced // from an identity provider (see [Group.IsSynced]). func (s *GroupsService) Delete(ctx context.Context, id string) error { - return s.client.do(ctx, "DELETE", "groups/"+id, nil, nil, nil) + if err := checkID("Group ID", id); err != nil { + return err + } + return s.client.do(ctx, "DELETE", buildPath("groups", id), nil, nil, nil) } diff --git a/groups_test.go b/groups_test.go index 8643ac0..72dfaea 100644 --- a/groups_test.go +++ b/groups_test.go @@ -39,6 +39,36 @@ func TestGroupsService_Get(t *testing.T) { }) } +func TestGroupsService_Update(t *testing.T) { + var gotMethod, gotPath string + var gotBody map[string]any + client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + decodeJSONBody(t, r, &gotBody) + testutil.JSONResponse(http.StatusOK, map[string]any{ + "data": map[string]any{"id": "group-1", "name": "renamed"}, + })(w, r) + })) + + group, err := client.Groups.Update(context.Background(), "group-1", &firezone.UpdateGroupRequest{Name: "renamed"}) + if err != nil { + t.Fatalf("Update returned error: %v", err) + } + if gotMethod != http.MethodPatch || gotPath != "/groups/group-1" { + t.Errorf("request = %s %s, want PATCH /groups/group-1", gotMethod, gotPath) + } + reqGroup, ok := gotBody["group"].(map[string]any) + if !ok { + t.Fatalf("body[\"group\"] = %v, want an object", gotBody["group"]) + } + if reqGroup["name"] != "renamed" { + t.Errorf("body group.name = %v, want renamed", reqGroup["name"]) + } + if group.Name != "renamed" { + t.Errorf("group.Name = %q, want renamed", group.Name) + } +} + func TestGroupsService_Update_SyncedGroupForbidden(t *testing.T) { client := testutil.NewClient(t, testutil.ProblemResponse(http.StatusForbidden, "Cannot update a synced Group")) diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..0e527b5 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,1303 @@ +//go:build integration + +// Acceptance tests that run against a real Firezone portal. +// +// Everything else in this package tests against httptest stubs, which +// can only prove the SDK is self-consistent: the fixtures are written by +// the same hand as the code they check, so a wrong assumption about the +// API is invisible. These tests are the only ones that can disagree with +// the SDK about how the server behaves. +// +// They need a portal and a token: +// +// export FIREZONE_ENDPOINT=https://localhost:13001 +// export FIREZONE_TOKEN=$(...) # see the README +// export FIREZONE_CA_CERT=.../priv/cert/selfsigned.pem +// mise run test-acceptance +// +// With either variable unset every test skips, so an untagged +// `go test ./...` never touches the network. CI does not run these - it +// only vets them (`go vet -tags=integration`), which is what keeps them +// compiling as the SDK changes. +// +// Tests create real objects. Every one is named with a run-scoped prefix +// and removed by t.Cleanup, so a run only ever deletes what it made and +// debris from a crashed run is identifiable by prefix. +package firezone_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "math/rand/v2" + "net/http" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + firezone "github.com/firezone/firezone-go" +) + +const ( + envEndpoint = "FIREZONE_ENDPOINT" + envToken = "FIREZONE_TOKEN" + // envCACert points at a PEM certificate to trust in addition to the + // system roots. The dev server serves the API over HTTPS with a + // self-signed certificate, which Go rejects unless it has been added + // to the machine's trust store - set this instead of relying on + // whether it happens to have been. + envCACert = "FIREZONE_CA_CERT" + + // namePrefix marks every object these tests create. Anything left + // behind by a crashed run is findable with it. + namePrefix = "gosdk" +) + +// runID distinguishes objects from concurrent or repeated runs, so a +// name collision can't fail a test for reasons that have nothing to do +// with the SDK. +var runID = fmt.Sprintf("%d%04d", time.Now().Unix()%100000, rand.IntN(10000)) + +// nameSeq keeps names unique within a run. +var nameSeq atomic.Int64 + +// uniqueName returns a run-scoped, collision-free object name. +func uniqueName(kind string) string { + return fmt.Sprintf("%s-%s-%s-%d", namePrefix, runID, kind, nameSeq.Add(1)) +} + +// integrationClient returns a client for the portal under test, skipping +// the test when the environment doesn't describe one. +func integrationClient(t *testing.T) *firezone.Client { + t.Helper() + + endpoint, token := os.Getenv(envEndpoint), os.Getenv(envToken) + if endpoint == "" || token == "" { + t.Skipf("set %s and %s to run acceptance tests", envEndpoint, envToken) + } + + opts := []firezone.Option{firezone.WithUserAgent("firezone-go-acceptance-tests")} + if caPath := os.Getenv(envCACert); caPath != "" { + opts = append(opts, firezone.WithHTTPClient(httpClientTrusting(t, caPath))) + } + + client, err := firezone.NewClient(endpoint, token, opts...) + if err != nil { + t.Fatalf("building client for %s: %v", endpoint, err) + } + return client +} + +// httpClientTrusting returns a client that trusts the PEM certificate at +// path on top of the system roots. +func httpClientTrusting(t *testing.T, path string) *http.Client { + t.Helper() + + pem, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s=%s: %v", envCACert, path, err) + } + pool, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } + if !pool.AppendCertsFromPEM(pem) { + t.Fatalf("%s=%s: no certificate found in the file", envCACert, path) + } + + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }, + } +} + +func ctx() context.Context { return context.Background() } + +// cleanup registers a deletion that tolerates the object already being +// gone - a test that deletes its own subject as part of what it asserts +// shouldn't fail in teardown for succeeding. +func cleanup(t *testing.T, what string, del func() error) { + t.Helper() + t.Cleanup(func() { + if err := del(); err != nil && !firezone.IsNotFound(err) { + t.Errorf("cleanup: deleting %s: %v", what, err) + } + }) +} + +// --- scratch fixtures ------------------------------------------------- + +func newSite(t *testing.T, c *firezone.Client) *firezone.Site { + t.Helper() + site, err := c.Sites.Create(ctx(), &firezone.CreateSiteRequest{Name: uniqueName("site")}) + if err != nil { + t.Fatalf("creating scratch Site: %v", err) + } + cleanup(t, "Site "+site.ID, func() error { return c.Sites.Delete(ctx(), site.ID) }) + return site +} + +func newGroup(t *testing.T, c *firezone.Client) *firezone.Group { + t.Helper() + group, err := c.Groups.Create(ctx(), &firezone.CreateGroupRequest{Name: uniqueName("group")}) + if err != nil { + t.Fatalf("creating scratch Group: %v", err) + } + cleanup(t, "Group "+group.ID, func() error { return c.Groups.Delete(ctx(), group.ID) }) + return group +} + +func newActor(t *testing.T, c *firezone.Client) *firezone.Actor { + t.Helper() + actor, err := c.Actors.Create(ctx(), &firezone.CreateActorRequest{ + Name: uniqueName("actor"), + Type: firezone.ActorTypeServiceAccount, + }) + if err != nil { + t.Fatalf("creating scratch Actor: %v", err) + } + cleanup(t, "Actor "+actor.ID, func() error { return c.Actors.Delete(ctx(), actor.ID) }) + return actor +} + +// newResource creates a CIDR Resource in its own Site. Each call uses a +// distinct address so concurrent runs don't collide on one. +func newResource(t *testing.T, c *firezone.Client, siteID string) *firezone.Resource { + t.Helper() + resource, err := c.Resources.Create(ctx(), &firezone.CreateResourceRequest{ + Name: uniqueName("resource"), + Type: firezone.ResourceTypeCIDR, + Address: fmt.Sprintf("10.%d.%d.0/24", rand.IntN(256), rand.IntN(256)), + AddressDescription: "created by the Go SDK acceptance tests", + SiteID: siteID, + }) + if err != nil { + t.Fatalf("creating scratch Resource: %v", err) + } + cleanup(t, "Resource "+resource.ID, func() error { return c.Resources.Delete(ctx(), resource.ID) }) + return resource +} + +// --- 1. merge-patch semantics ---------------------------------------- + +// TestIntegration_MergePatchSemantics is the reason this file exists. +// +// The Null[T] design rests on a reading of the server's changeset code: +// that an absent key leaves a field alone, an explicit null clears it, +// and an empty string is replaced by the field's default. Every unit +// test asserting that only proves the SDK marshals what the SDK intends. +// This is the one place the server gets a vote - and it has already +// overruled one of those assumptions once. +func TestIntegration_MergePatchSemantics(t *testing.T) { + c := integrationClient(t) + site := newSite(t, c) + + t.Run("a nullable field survives an update that omits it", func(t *testing.T) { + resource := newResource(t, c, site.ID) + if resource.AddressDescription == "" { + t.Fatal("fixture Resource has no address description to preserve") + } + + updated, err := c.Resources.Update(ctx(), resource.ID, &firezone.UpdateResourceRequest{ + Name: uniqueName("resource"), + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.AddressDescription != resource.AddressDescription { + t.Errorf("AddressDescription = %q after an update that omitted it, want it unchanged (%q)", + updated.AddressDescription, resource.AddressDescription) + } + }) + + t.Run("Clear removes a nullable field", func(t *testing.T) { + resource := newResource(t, c, site.ID) + + updated, err := c.Resources.Update(ctx(), resource.ID, &firezone.UpdateResourceRequest{ + AddressDescription: firezone.Clear[string](), + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.AddressDescription != "" { + t.Errorf("AddressDescription = %q after Clear, want it empty", updated.AddressDescription) + } + + // Re-read rather than trusting the update response: the two can + // disagree, and the stored value is what matters. + got, err := c.Resources.Get(ctx(), resource.ID) + if err != nil { + t.Fatalf("Get after Clear: %v", err) + } + if got.AddressDescription != "" { + t.Errorf("AddressDescription = %q on re-read after Clear, want it empty", got.AddressDescription) + } + }) + + t.Run("Set replaces a nullable field", func(t *testing.T) { + resource := newResource(t, c, site.ID) + + updated, err := c.Resources.Update(ctx(), resource.ID, &firezone.UpdateResourceRequest{ + AddressDescription: firezone.Set("replaced by the acceptance tests"), + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if want := "replaced by the acceptance tests"; updated.AddressDescription != want { + t.Errorf("AddressDescription = %q, want %q", updated.AddressDescription, want) + } + }) + + // The API's changeset replaces an empty string with the field's + // default rather than storing it, and for a nullable field that + // default is null - so Set("") clears, exactly as Clear does. The + // SDK originally documented the opposite; this test is what + // corrected it, and it is here to keep the documented behavior and + // the real one from drifting apart again. + t.Run("Set of the empty string clears the field, like Clear", func(t *testing.T) { + resource := newResource(t, c, site.ID) + + updated, err := c.Resources.Update(ctx(), resource.ID, &firezone.UpdateResourceRequest{ + AddressDescription: firezone.Set(""), + }) + if err != nil { + t.Fatalf("Update with Set(\"\"): %v", err) + } + if updated.AddressDescription != "" { + t.Errorf("AddressDescription = %q after Set(\"\"), want it cleared.\n"+ + "If the API now stores or rejects the empty string instead, the Null doc "+ + "comment and the README's \"Updating nullable fields\" section need updating.", + updated.AddressDescription) + } + + got, err := c.Resources.Get(ctx(), resource.ID) + if err != nil { + t.Fatalf("Get after Set(\"\"): %v", err) + } + if got.AddressDescription != "" { + t.Errorf("AddressDescription = %q on re-read, want it cleared", got.AddressDescription) + } + }) + + t.Run("an empty Filters slice removes every filter", func(t *testing.T) { + resource := newResource(t, c, site.ID) + + withFilters, err := c.Resources.Update(ctx(), resource.ID, &firezone.UpdateResourceRequest{ + Filters: &[]firezone.Filter{{Protocol: firezone.FilterProtocolTCP, Ports: []string{"5432"}}}, + }) + if err != nil { + t.Fatalf("Update adding a filter: %v", err) + } + if len(withFilters.Filters) != 1 { + t.Fatalf("Filters = %+v after adding one, want exactly one", withFilters.Filters) + } + + cleared, err := c.Resources.Update(ctx(), resource.ID, &firezone.UpdateResourceRequest{ + Filters: &[]firezone.Filter{}, + }) + if err != nil { + t.Fatalf("Update clearing filters: %v", err) + } + if len(cleared.Filters) != 0 { + t.Errorf("Filters = %+v after sending an empty slice, want none", cleared.Filters) + } + }) + + t.Run("a nil Filters pointer leaves the filters alone", func(t *testing.T) { + resource := newResource(t, c, site.ID) + + if _, err := c.Resources.Update(ctx(), resource.ID, &firezone.UpdateResourceRequest{ + Filters: &[]firezone.Filter{{Protocol: firezone.FilterProtocolTCP, Ports: []string{"443"}}}, + }); err != nil { + t.Fatalf("Update adding a filter: %v", err) + } + + updated, err := c.Resources.Update(ctx(), resource.ID, &firezone.UpdateResourceRequest{ + Name: uniqueName("resource"), + }) + if err != nil { + t.Fatalf("Update omitting filters: %v", err) + } + if len(updated.Filters) != 1 { + t.Errorf("Filters = %+v after an update that omitted them, want the one filter preserved", + updated.Filters) + } + }) + + t.Run("an empty Conditions slice removes every Policy condition", func(t *testing.T) { + group := newGroup(t, c) + resource := newResource(t, c, site.ID) + + policy, err := c.Policies.Create(ctx(), &firezone.CreatePolicyRequest{ + GroupID: group.ID, + ResourceID: resource.ID, + Conditions: []firezone.Condition{{ + Property: firezone.ConditionPropertyRemoteIPLocationRegion, + Operator: firezone.ConditionOperatorIsIn, + Values: []string{"US"}, + }}, + }) + if err != nil { + t.Fatalf("Create with a condition: %v", err) + } + cleanup(t, "Policy "+policy.ID, func() error { return c.Policies.Delete(ctx(), policy.ID) }) + + if len(policy.Conditions) != 1 { + t.Fatalf("Conditions = %+v on create, want exactly one", policy.Conditions) + } + + cleared, err := c.Policies.Update(ctx(), policy.ID, &firezone.UpdatePolicyRequest{ + Conditions: &[]firezone.Condition{}, + }) + if err != nil { + t.Fatalf("Update clearing conditions: %v", err) + } + if len(cleared.Conditions) != 0 { + t.Errorf("Conditions = %+v after sending an empty slice, want none.\n"+ + "This is the case a plain []Condition could not express at all.", cleared.Conditions) + } + }) + + t.Run("Clear removes a Policy description", func(t *testing.T) { + group := newGroup(t, c) + resource := newResource(t, c, site.ID) + + policy, err := c.Policies.Create(ctx(), &firezone.CreatePolicyRequest{ + GroupID: group.ID, + ResourceID: resource.ID, + Description: "created by the Go SDK acceptance tests", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + cleanup(t, "Policy "+policy.ID, func() error { return c.Policies.Delete(ctx(), policy.ID) }) + + cleared, err := c.Policies.Update(ctx(), policy.ID, &firezone.UpdatePolicyRequest{ + Description: firezone.Clear[string](), + }) + if err != nil { + t.Fatalf("Update clearing the description: %v", err) + } + if cleared.Description != "" { + t.Errorf("Description = %q after Clear, want it empty", cleared.Description) + } + }) +} + +// --- 2. error envelopes ---------------------------------------------- + +// TestIntegration_ErrorEnvelopes checks that parseAPIError reads what the +// server actually sends. The unit tests assert against +// internal/testutil's hand-written problem+json, which is a guess at the +// real shape. +func TestIntegration_ErrorEnvelopes(t *testing.T) { + c := integrationClient(t) + + // A syntactically valid UUID that will not exist. + const missingID = "00000000-0000-4000-8000-000000000000" + + t.Run("404 on a missing Site", func(t *testing.T) { + _, err := c.Sites.Get(ctx(), missingID) + if !firezone.IsNotFound(err) { + t.Fatalf("error = %v, want a 404", err) + } + + var apiErr *firezone.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error %v is not an *APIError", err) + } + if apiErr.StatusCode != 404 { + t.Errorf("StatusCode = %d, want 404", apiErr.StatusCode) + } + if apiErr.Title == "" { + t.Error("Title is empty; the problem+json body did not parse as expected") + } + }) + + t.Run("422 carries field-level validation errors", func(t *testing.T) { + _, err := c.Sites.Create(ctx(), &firezone.CreateSiteRequest{Name: ""}) + if !firezone.IsValidation(err) { + t.Fatalf("error = %v, want a 422", err) + } + + var apiErr *firezone.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error %v is not an *APIError", err) + } + if len(apiErr.ValidationErrors) == 0 { + t.Fatal("ValidationErrors is empty; the API's field-level detail is not being parsed") + } + if _, ok := apiErr.ValidationErrors["name"]; !ok { + t.Errorf("ValidationErrors = %v, want an entry keyed \"name\"", apiErr.ValidationErrors) + } + }) + + // A duplicate name is a 422 with a field-level error, not the 409 a + // reader might expect: the server enforces uniqueness with a + // changeset unique_constraint, which surfaces as validation. The + // README's opening example said otherwise until this test ran. + t.Run("a duplicate Site name is a validation error, not a conflict", func(t *testing.T) { + name := uniqueName("dup") + first, err := c.Sites.Create(ctx(), &firezone.CreateSiteRequest{Name: name}) + if err != nil { + t.Fatalf("Create: %v", err) + } + cleanup(t, "Site "+first.ID, func() error { return c.Sites.Delete(ctx(), first.ID) }) + + _, err = c.Sites.Create(ctx(), &firezone.CreateSiteRequest{Name: name}) + if !firezone.IsValidation(err) { + t.Fatalf("error = %v, want a 422; if the API has moved to 409 for duplicates, "+ + "the README's opening example and its Is* notes need updating", err) + } + if firezone.IsConflict(err) { + t.Error("IsConflict(err) = true; a duplicate name should not be reported as a 409") + } + + var apiErr *firezone.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error %v is not an *APIError", err) + } + if _, ok := apiErr.ValidationErrors["name"]; !ok { + t.Errorf("ValidationErrors = %v, want an entry keyed \"name\"", apiErr.ValidationErrors) + } + }) +} + +// --- 3. pagination --------------------------------------------------- + +// TestIntegration_Pagination walks a cursor to exhaustion. Cursor +// semantics - whether next_page is inclusive, whether count is the total +// or the page size - are pure assumption everywhere else. +func TestIntegration_Pagination(t *testing.T) { + c := integrationClient(t) + + const created = 5 + want := map[string]bool{} + for i := 0; i < created; i++ { + want[newSite(t, c).ID] = false + } + + seen := map[string]int{} + var pages int + opts := &firezone.SiteListOptions{ListOptions: firezone.ListOptions{Limit: 2}} + for { + page, err := c.Sites.List(ctx(), opts) + if err != nil { + t.Fatalf("List page %d: %v", pages+1, err) + } + pages++ + + if len(page.Data) > 2 { + t.Errorf("page %d returned %d Sites, want at most the requested limit of 2", pages, len(page.Data)) + } + for _, site := range page.Data { + seen[site.ID]++ + if _, ours := want[site.ID]; ours { + want[site.ID] = true + } + } + + if page.Metadata.NextPage == "" { + if page.Metadata.Count < created { + t.Errorf("Metadata.Count = %d, want at least the %d Sites this test created - "+ + "count should be the total across pages, not the page size", + page.Metadata.Count, created) + } + break + } + opts.PageCursor = page.Metadata.NextPage + + // A cursor that never terminates would otherwise hang the suite. + if pages > 100 { + t.Fatal("cursor did not terminate after 100 pages") + } + } + + for id, found := range want { + if !found { + t.Errorf("Site %s was created but never appeared while paging", id) + } + } + for id, n := range seen { + if n > 1 { + t.Errorf("Site %s appeared on %d pages, want exactly one", id, n) + } + } + t.Logf("walked %d pages at limit 2", pages) +} + +// --- 4. gateway provisioning ----------------------------------------- + +// TestIntegration_GatewayProvisionAndRotate covers the one-time token +// secrets. Both are shown exactly once, so a fixture asserting their +// shape proves nothing about whether the API really returns them there. +func TestIntegration_GatewayProvisionAndRotate(t *testing.T) { + c := integrationClient(t) + site := newSite(t, c) + gateways := c.Sites.Gateways(site.ID) + + provisioned, err := gateways.Provision(ctx(), &firezone.ProvisionGatewayRequest{ + Name: uniqueName("gw"), + }) + if err != nil { + t.Fatalf("Provision: %v", err) + } + cleanup(t, "Gateway "+provisioned.ID, func() error { return gateways.Delete(ctx(), provisioned.ID) }) + + if provisioned.ID == "" { + t.Error("provisioned Gateway has no ID") + } + if provisioned.Token == "" { + t.Error("Provision returned no Token; it is the only call that ever exposes one") + } + + got, err := gateways.Get(ctx(), provisioned.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.ID != provisioned.ID { + t.Errorf("Get returned Gateway %s, want %s", got.ID, provisioned.ID) + } + + page, err := gateways.List(ctx(), nil) + if err != nil { + t.Fatalf("List: %v", err) + } + if !containsGatewayID(page.Data, provisioned.ID) { + t.Errorf("List did not include the newly provisioned Gateway %s", provisioned.ID) + } + + rotated, err := gateways.RotateToken(ctx(), provisioned.ID) + if err != nil { + t.Fatalf("RotateToken: %v", err) + } + if rotated.Token == "" { + t.Error("RotateToken returned no Token") + } + if rotated.Token == provisioned.Token { + t.Error("RotateToken returned the original token; rotation must mint a new secret") + } + if rotated.ID == "" { + t.Error("RotateToken returned no token ID") + } +} + +func containsGatewayID(gateways []firezone.Gateway, id string) bool { + for _, g := range gateways { + if g.ID == id { + return true + } + } + return false +} + +// --- 5. CRUD round trips --------------------------------------------- + +// TestIntegration_SiteCRUD and its siblings are shallow by design: they +// exist to prove each method's path, verb and envelope against a real +// router, which is what no stub can do. +func TestIntegration_SiteCRUD(t *testing.T) { + c := integrationClient(t) + + name := uniqueName("site") + site, err := c.Sites.Create(ctx(), &firezone.CreateSiteRequest{Name: name}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if site.Name != name { + t.Errorf("Name = %q, want %q", site.Name, name) + } + + got, err := c.Sites.Get(ctx(), site.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.ID != site.ID { + t.Errorf("Get returned %s, want %s", got.ID, site.ID) + } + + renamed := uniqueName("site") + updated, err := c.Sites.Update(ctx(), site.ID, &firezone.UpdateSiteRequest{Name: renamed}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Name != renamed { + t.Errorf("Name = %q after Update, want %q", updated.Name, renamed) + } + + page, err := c.Sites.List(ctx(), &firezone.SiteListOptions{Name: renamed}) + if err != nil { + t.Fatalf("List filtered by name: %v", err) + } + if len(page.Data) != 1 || page.Data[0].ID != site.ID { + t.Errorf("List by name returned %+v, want exactly the updated Site", page.Data) + } + + if err := c.Sites.Delete(ctx(), site.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := c.Sites.Get(ctx(), site.ID); !firezone.IsNotFound(err) { + t.Errorf("Get after Delete returned %v, want a 404", err) + } +} + +func TestIntegration_ResourceCRUD(t *testing.T) { + c := integrationClient(t) + site := newSite(t, c) + + name := uniqueName("resource") + resource, err := c.Resources.Create(ctx(), &firezone.CreateResourceRequest{ + Name: name, + Type: firezone.ResourceTypeCIDR, + Address: fmt.Sprintf("10.%d.%d.0/24", rand.IntN(256), rand.IntN(256)), + SiteID: site.ID, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if resource.Type != firezone.ResourceTypeCIDR { + t.Errorf("Type = %q, want cidr", resource.Type) + } + if resource.SiteID != site.ID { + t.Errorf("SiteID = %q, want %q", resource.SiteID, site.ID) + } + + if _, err := c.Resources.Get(ctx(), resource.ID); err != nil { + t.Fatalf("Get: %v", err) + } + + renamed := uniqueName("resource") + updated, err := c.Resources.Update(ctx(), resource.ID, &firezone.UpdateResourceRequest{Name: renamed}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Name != renamed { + t.Errorf("Name = %q after Update, want %q", updated.Name, renamed) + } + + page, err := c.Resources.List(ctx(), &firezone.ResourceListOptions{SiteID: site.ID}) + if err != nil { + t.Fatalf("List filtered by site_id: %v", err) + } + if len(page.Data) != 1 || page.Data[0].ID != resource.ID { + t.Errorf("List by site_id returned %+v, want exactly the created Resource", page.Data) + } + + if err := c.Resources.Delete(ctx(), resource.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := c.Resources.Get(ctx(), resource.ID); !firezone.IsNotFound(err) { + t.Errorf("Get after Delete returned %v, want a 404", err) + } +} + +func TestIntegration_PolicyCRUD(t *testing.T) { + c := integrationClient(t) + site := newSite(t, c) + group := newGroup(t, c) + resource := newResource(t, c, site.ID) + + policy, err := c.Policies.Create(ctx(), &firezone.CreatePolicyRequest{ + GroupID: group.ID, + ResourceID: resource.ID, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if policy.GroupID != group.ID || policy.ResourceID != resource.ID { + t.Errorf("Policy = %+v, want it to link Group %s to Resource %s", policy, group.ID, resource.ID) + } + + if _, err := c.Policies.Get(ctx(), policy.ID); err != nil { + t.Fatalf("Get: %v", err) + } + + disabled, err := c.Policies.Disable(ctx(), policy.ID) + if err != nil { + t.Fatalf("Disable: %v", err) + } + if !disabled.IsDisabled { + t.Error("IsDisabled = false after Disable") + } + + enabled, err := c.Policies.Enable(ctx(), policy.ID) + if err != nil { + t.Fatalf("Enable: %v", err) + } + if enabled.IsDisabled { + t.Error("IsDisabled = true after Enable") + } + + page, err := c.Policies.List(ctx(), &firezone.PolicyListOptions{GroupID: group.ID}) + if err != nil { + t.Fatalf("List filtered by group_id: %v", err) + } + if len(page.Data) != 1 || page.Data[0].ID != policy.ID { + t.Errorf("List by group_id returned %+v, want exactly the created Policy", page.Data) + } + + if err := c.Policies.Delete(ctx(), policy.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := c.Policies.Get(ctx(), policy.ID); !firezone.IsNotFound(err) { + t.Errorf("Get after Delete returned %v, want a 404", err) + } +} + +func TestIntegration_GroupCRUD(t *testing.T) { + c := integrationClient(t) + + name := uniqueName("group") + group, err := c.Groups.Create(ctx(), &firezone.CreateGroupRequest{Name: name}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if group.Name != name { + t.Errorf("Name = %q, want %q", group.Name, name) + } + if group.IsSynced() { + t.Error("IsSynced() = true for a Group created through the API") + } + + if _, err := c.Groups.Get(ctx(), group.ID); err != nil { + t.Fatalf("Get: %v", err) + } + + renamed := uniqueName("group") + updated, err := c.Groups.Update(ctx(), group.ID, &firezone.UpdateGroupRequest{Name: renamed}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Name != renamed { + t.Errorf("Name = %q after Update, want %q", updated.Name, renamed) + } + + // A pointer to the empty string filters to unsynced Groups, which is + // the distinction GroupListOptions.DirectoryID exists to express. + page, err := c.Groups.List(ctx(), &firezone.GroupListOptions{ + Name: renamed, + DirectoryID: firezone.String(""), + }) + if err != nil { + t.Fatalf("List filtered by name and unsynced: %v", err) + } + if len(page.Data) != 1 || page.Data[0].ID != group.ID { + t.Errorf("List returned %+v, want exactly the updated Group", page.Data) + } + + if err := c.Groups.Delete(ctx(), group.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := c.Groups.Get(ctx(), group.ID); !firezone.IsNotFound(err) { + t.Errorf("Get after Delete returned %v, want a 404", err) + } +} + +func TestIntegration_ActorCRUD(t *testing.T) { + c := integrationClient(t) + + name := uniqueName("actor") + actor, err := c.Actors.Create(ctx(), &firezone.CreateActorRequest{ + Name: name, + Type: firezone.ActorTypeServiceAccount, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if actor.Type != firezone.ActorTypeServiceAccount { + t.Errorf("Type = %q, want service_account", actor.Type) + } + if actor.IsSynced() { + t.Error("IsSynced() = true for an Actor created through the API") + } + + if _, err := c.Actors.Get(ctx(), actor.ID); err != nil { + t.Fatalf("Get: %v", err) + } + + renamed := uniqueName("actor") + updated, err := c.Actors.Update(ctx(), actor.ID, &firezone.UpdateActorRequest{Name: renamed}) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Name != renamed { + t.Errorf("Name = %q after Update, want %q", updated.Name, renamed) + } + + disabled, err := c.Actors.Disable(ctx(), actor.ID) + if err != nil { + t.Fatalf("Disable: %v", err) + } + if !disabled.IsDisabled { + t.Error("IsDisabled = false after Disable") + } + + enabled, err := c.Actors.Enable(ctx(), actor.ID) + if err != nil { + t.Fatalf("Enable: %v", err) + } + if enabled.IsDisabled { + t.Error("IsDisabled = true after Enable") + } + + page, err := c.Actors.List(ctx(), &firezone.ActorListOptions{Name: renamed}) + if err != nil { + t.Fatalf("List filtered by name: %v", err) + } + if len(page.Data) != 1 || page.Data[0].ID != actor.ID { + t.Errorf("List by name returned %+v, want exactly the updated Actor", page.Data) + } + + if err := c.Actors.Delete(ctx(), actor.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := c.Actors.Get(ctx(), actor.ID); !firezone.IsNotFound(err) { + t.Errorf("Get after Delete returned %v, want a 404", err) + } +} + +func TestIntegration_Memberships(t *testing.T) { + c := integrationClient(t) + group := newGroup(t, c) + first, second := newActor(t, c), newActor(t, c) + memberships := c.Groups.Memberships(group.ID) + + ids, err := memberships.ReplaceAll(ctx(), []string{first.ID}) + if err != nil { + t.Fatalf("ReplaceAll: %v", err) + } + if len(ids) != 1 || ids[0] != first.ID { + t.Errorf("ReplaceAll returned %v, want [%s]", ids, first.ID) + } + + // Patch is the additive form: adding one and removing the other in a + // single call is what distinguishes it from ReplaceAll. + ids, err = memberships.Patch(ctx(), []string{second.ID}, []string{first.ID}) + if err != nil { + t.Fatalf("Patch: %v", err) + } + if len(ids) != 1 || ids[0] != second.ID { + t.Errorf("Patch returned %v, want [%s]", ids, second.ID) + } + + page, err := memberships.List(ctx(), nil) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(page.Data) != 1 || page.Data[0].ID != second.ID { + t.Errorf("List returned %+v, want exactly Actor %s", page.Data, second.ID) + } + + ids, err = memberships.ReplaceAll(ctx(), []string{}) + if err != nil { + t.Fatalf("ReplaceAll with an empty list: %v", err) + } + if len(ids) != 0 { + t.Errorf("ReplaceAll([]) returned %v, want no members", ids) + } +} + +// TestIntegration_PoolMembers needs a static_device_pool Resource, which +// the API refuses to create - pools are made in the admin portal. The +// test discovers one and skips when the account has none. +func TestIntegration_PoolMembers(t *testing.T) { + c := integrationClient(t) + + pools, err := c.Resources.List(ctx(), &firezone.ResourceListOptions{ + Type: firezone.ResourceTypeStaticDevicePool, + }) + if err != nil { + t.Fatalf("listing static device pools: %v", err) + } + if len(pools.Data) == 0 { + t.Skip("no static_device_pool Resource in this account; create one in the admin portal to cover this") + } + + pool := pools.Data[0] + page, err := c.Resources.PoolMembers(pool.ID).List(ctx(), nil) + if err != nil { + t.Fatalf("PoolMembers.List for pool %s: %v", pool.ID, err) + } + t.Logf("pool %s has %d member(s) of %d total", pool.ID, len(page.Data), page.Metadata.Count) + + // A pool with members is the only chance to check that PoolMember + // decodes; the spec marks id and name required and non-nullable. + for i := range page.Data { + member := page.Data[i] + nonEmpty(t, "PoolMember.ID", member.ID) + nonEmpty(t, "PoolMember.Name", member.Name) + // LastSeenAt is nullable - a pooled device that has never + // connected has none. + t.Logf("member %s (%s) last seen %v", member.ID, member.Name, member.LastSeenAt) + } + + // Membership is not modified here: the members are real enrolled + // devices belonging to whoever owns this account, and ReplaceAll + // would evict them. Deepen this only against a throwaway account. +} + +// TestIntegration_ClientDevices is read-only: Client devices enroll +// themselves when a real client connects, so there is nothing for a test +// to create. +func TestIntegration_ClientDevices(t *testing.T) { + c := integrationClient(t) + + page, err := c.ClientDevices.List(ctx(), nil) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(page.Data) == 0 { + t.Skip("no enrolled Client devices in this account; connect a client to cover Get and Verify") + } + + first := page.Data[0] + got, err := c.ClientDevices.Get(ctx(), first.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.ID != first.ID { + t.Errorf("Get returned %s, want %s", got.ID, first.ID) + } + if got.FirezoneID == "" { + t.Error("FirezoneID is empty; every enrolled device should carry one") + } + + // Verify/Unverify and Update are left alone: they mutate devices + // belonging to whoever owns this account. +} + +// --- 6. read-only services ------------------------------------------- + +// checkReadOnly runs the shared contract for a read-only list/get +// service: every record decodes with its always-populated fields +// populated, Get agrees with List field for field, and the name filter +// is honoured by the server. +// +// It skips when the account has no records of this kind. That is a real +// gap rather than a pass - the field mappings for these types are only +// verified when something exists to verify them against. +func checkReadOnly[T any]( + t *testing.T, + kind string, + list func(nameFilter string) (*firezone.Page[T], error), + get func(id string) (*T, error), + idOf func(*T) string, + nameOf func(*T) string, + verify func(t *testing.T, rec *T), +) { + t.Helper() + + page, err := list("") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(page.Data) == 0 { + t.Skipf("no %s configured in this account; its field mappings stay unverified", kind) + } + + for i := range page.Data { + rec := page.Data[i] + t.Run(fmt.Sprintf("record %s", idOf(&rec)), func(t *testing.T) { + verify(t, &rec) + + // Get and List render through the same view on the server, + // so any difference is a bug in one of them - a field the + // index omits, or a type that decodes differently. + got, err := get(idOf(&rec)) + if err != nil { + t.Fatalf("Get: %v", err) + } + if fromList, fromGet := mustJSON(t, rec), mustJSON(t, *got); fromList != fromGet { + t.Errorf("List and Get disagree for %s:\n List: %s\n Get: %s", + idOf(&rec), fromList, fromGet) + } + }) + } + + // The name filter is built by the SDK and honoured by the server; + // unit tests only cover the half the SDK owns. + first := page.Data[0] + if name := nameOf(&first); name != "" { + filtered, err := list(name) + if err != nil { + t.Fatalf("List filtered by name %q: %v", name, err) + } + var found bool + for i := range filtered.Data { + if idOf(&filtered.Data[i]) == idOf(&first) { + found = true + } + } + if !found { + t.Errorf("List(name=%q) did not return %s; the server may not honour the filter", + name, idOf(&first)) + } + } +} + +// mustJSON renders a value for comparison and for failure messages. +// Comparing marshalled JSON rather than using reflect.DeepEqual keeps +// time.Time values from differing over location or monotonic readings, +// and makes a mismatch readable. +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshalling %T: %v", v, err) + } + return string(b) +} + +func nonEmpty(t *testing.T, field, value string) { + t.Helper() + if value == "" { + t.Errorf("%s is empty; the spec marks it required and non-nullable, so either the "+ + "JSON tag is wrong or this record is misconfigured", field) + } +} + +func nonZeroTime(t *testing.T, field string, value time.Time) { + t.Helper() + if value.IsZero() { + t.Errorf("%s is the zero time; the spec marks it required and non-nullable, so either "+ + "the JSON tag is wrong or the timestamp did not parse", field) + } +} + +// verifyAuthProviderBase checks the fields every provider type shares. +// +// The session lifetimes are deliberately not asserted to be positive. +// They are nil whenever the provider sets no override, which is the +// normal state - asserting otherwise is what failed the first time these +// tests ran against a real portal, and it was the assertion that was +// wrong, not the data. A non-nil value must still be positive, since the +// server validates a configured lifetime against a minimum. +func verifyAuthProviderBase(t *testing.T, p *firezone.AuthProvider) { + t.Helper() + + nonEmpty(t, "ID", p.ID) + nonEmpty(t, "AccountID", p.AccountID) + nonEmpty(t, "Name", p.Name) + nonEmpty(t, "Issuer", p.Issuer) + nonEmpty(t, "Context", p.Context) + nonZeroTime(t, "InsertedAt", p.InsertedAt) + nonZeroTime(t, "UpdatedAt", p.UpdatedAt) + + checkLifetime(t, "ClientSessionLifetimeSecs", p.ClientSessionLifetimeSecs) + checkLifetime(t, "PortalSessionLifetimeSecs", p.PortalSessionLifetimeSecs) +} + +func checkLifetime(t *testing.T, field string, secs *int) { + t.Helper() + if secs == nil { + t.Logf("%s is nil (no override; Firezone's default applies)", field) + return + } + if *secs <= 0 { + t.Errorf("%s = %d, want a positive duration when it is set at all", field, *secs) + } +} + +func TestIntegration_AuthProviders(t *testing.T) { + c := integrationClient(t) + opts := func(name string) *firezone.AuthProviderListOptions { + return &firezone.AuthProviderListOptions{Name: name} + } + + t.Run("email OTP", func(t *testing.T) { + checkReadOnly(t, "email OTP auth providers", + func(n string) (*firezone.Page[firezone.EmailOTPAuthProvider], error) { + return c.EmailOTPAuthProviders.List(ctx(), opts(n)) + }, + func(id string) (*firezone.EmailOTPAuthProvider, error) { + return c.EmailOTPAuthProviders.Get(ctx(), id) + }, + func(p *firezone.EmailOTPAuthProvider) string { return p.ID }, + func(p *firezone.EmailOTPAuthProvider) string { return p.Name }, + func(t *testing.T, p *firezone.EmailOTPAuthProvider) { + verifyAuthProviderBase(t, &p.AuthProvider) + }) + }) + + t.Run("OIDC", func(t *testing.T) { + checkReadOnly(t, "OIDC auth providers", + func(n string) (*firezone.Page[firezone.OIDCAuthProvider], error) { + return c.OIDCAuthProviders.List(ctx(), opts(n)) + }, + func(id string) (*firezone.OIDCAuthProvider, error) { + return c.OIDCAuthProviders.Get(ctx(), id) + }, + func(p *firezone.OIDCAuthProvider) string { return p.ID }, + func(p *firezone.OIDCAuthProvider) string { return p.Name }, + func(t *testing.T, p *firezone.OIDCAuthProvider) { + verifyAuthProviderBase(t, &p.AuthProvider) + nonEmpty(t, "ClientID", p.ClientID) + nonEmpty(t, "DiscoveryDocumentURI", p.DiscoveryDocumentURI) + nonEmpty(t, "EmailVerificationMethod", p.EmailVerificationMethod) + }) + }) + + t.Run("Google", func(t *testing.T) { + checkReadOnly(t, "Google auth providers", + func(n string) (*firezone.Page[firezone.GoogleAuthProvider], error) { + return c.GoogleAuthProviders.List(ctx(), opts(n)) + }, + func(id string) (*firezone.GoogleAuthProvider, error) { + return c.GoogleAuthProviders.Get(ctx(), id) + }, + func(p *firezone.GoogleAuthProvider) string { return p.ID }, + func(p *firezone.GoogleAuthProvider) string { return p.Name }, + func(t *testing.T, p *firezone.GoogleAuthProvider) { + verifyAuthProviderBase(t, &p.AuthProvider) + }) + }) + + t.Run("Entra", func(t *testing.T) { + checkReadOnly(t, "Entra auth providers", + func(n string) (*firezone.Page[firezone.EntraAuthProvider], error) { + return c.EntraAuthProviders.List(ctx(), opts(n)) + }, + func(id string) (*firezone.EntraAuthProvider, error) { + return c.EntraAuthProviders.Get(ctx(), id) + }, + func(p *firezone.EntraAuthProvider) string { return p.ID }, + func(p *firezone.EntraAuthProvider) string { return p.Name }, + func(t *testing.T, p *firezone.EntraAuthProvider) { + verifyAuthProviderBase(t, &p.AuthProvider) + nonEmpty(t, "EmailClaim", p.EmailClaim) + }) + }) + + t.Run("Okta", func(t *testing.T) { + checkReadOnly(t, "Okta auth providers", + func(n string) (*firezone.Page[firezone.OktaAuthProvider], error) { + return c.OktaAuthProviders.List(ctx(), opts(n)) + }, + func(id string) (*firezone.OktaAuthProvider, error) { + return c.OktaAuthProviders.Get(ctx(), id) + }, + func(p *firezone.OktaAuthProvider) string { return p.ID }, + func(p *firezone.OktaAuthProvider) string { return p.Name }, + func(t *testing.T, p *firezone.OktaAuthProvider) { + verifyAuthProviderBase(t, &p.AuthProvider) + nonEmpty(t, "ClientID", p.ClientID) + nonEmpty(t, "OktaDomain", p.OktaDomain) + }) + }) +} + +// logSyncState reports the nullable sync fields every directory type +// shares. They are only populated once a sync has run or failed, so they +// cannot be asserted - but seeing which are set is what tells us whether +// the mapping has ever been exercised against real data. +func logSyncState(t *testing.T, disabledReason, errorMessage string, syncedAt, erroredAt *time.Time) { + t.Helper() + t.Logf("nullable sync state: SyncedAt=%v ErroredAt=%v DisabledReason=%q ErrorMessage=%q", + syncedAt, erroredAt, disabledReason, errorMessage) +} + +func TestIntegration_Directories(t *testing.T) { + c := integrationClient(t) + opts := func(name string) *firezone.DirectoryListOptions { + return &firezone.DirectoryListOptions{Name: name} + } + + t.Run("Entra", func(t *testing.T) { + checkReadOnly(t, "Entra directories", + func(n string) (*firezone.Page[firezone.EntraDirectory], error) { + return c.EntraDirectories.List(ctx(), opts(n)) + }, + func(id string) (*firezone.EntraDirectory, error) { + return c.EntraDirectories.Get(ctx(), id) + }, + func(d *firezone.EntraDirectory) string { return d.ID }, + func(d *firezone.EntraDirectory) string { return d.Name }, + func(t *testing.T, d *firezone.EntraDirectory) { + nonEmpty(t, "ID", d.ID) + nonEmpty(t, "AccountID", d.AccountID) + nonEmpty(t, "Name", d.Name) + nonEmpty(t, "TenantID", d.TenantID) + nonEmpty(t, "EmailField", d.EmailField) + nonZeroTime(t, "InsertedAt", d.InsertedAt) + nonZeroTime(t, "UpdatedAt", d.UpdatedAt) + logSyncState(t, d.DisabledReason, d.ErrorMessage, d.SyncedAt, d.ErroredAt) + }) + }) + + t.Run("Google", func(t *testing.T) { + checkReadOnly(t, "Google directories", + func(n string) (*firezone.Page[firezone.GoogleDirectory], error) { + return c.GoogleDirectories.List(ctx(), opts(n)) + }, + func(id string) (*firezone.GoogleDirectory, error) { + return c.GoogleDirectories.Get(ctx(), id) + }, + func(d *firezone.GoogleDirectory) string { return d.ID }, + func(d *firezone.GoogleDirectory) string { return d.Name }, + func(t *testing.T, d *firezone.GoogleDirectory) { + nonEmpty(t, "ID", d.ID) + nonEmpty(t, "AccountID", d.AccountID) + nonEmpty(t, "Name", d.Name) + nonEmpty(t, "Domain", d.Domain) + nonEmpty(t, "ImpersonationEmail", d.ImpersonationEmail) + nonEmpty(t, "GroupSyncMode", d.GroupSyncMode) + nonZeroTime(t, "InsertedAt", d.InsertedAt) + nonZeroTime(t, "UpdatedAt", d.UpdatedAt) + logSyncState(t, d.DisabledReason, d.ErrorMessage, d.SyncedAt, d.ErroredAt) + }) + }) + + t.Run("Okta", func(t *testing.T) { + checkReadOnly(t, "Okta directories", + func(n string) (*firezone.Page[firezone.OktaDirectory], error) { + return c.OktaDirectories.List(ctx(), opts(n)) + }, + func(id string) (*firezone.OktaDirectory, error) { + return c.OktaDirectories.Get(ctx(), id) + }, + func(d *firezone.OktaDirectory) string { return d.ID }, + func(d *firezone.OktaDirectory) string { return d.Name }, + func(t *testing.T, d *firezone.OktaDirectory) { + nonEmpty(t, "ID", d.ID) + nonEmpty(t, "AccountID", d.AccountID) + nonEmpty(t, "Name", d.Name) + nonEmpty(t, "ClientID", d.ClientID) + nonEmpty(t, "Kid", d.Kid) + nonEmpty(t, "OktaDomain", d.OktaDomain) + nonZeroTime(t, "InsertedAt", d.InsertedAt) + nonZeroTime(t, "UpdatedAt", d.UpdatedAt) + logSyncState(t, d.DisabledReason, d.ErrorMessage, d.SyncedAt, d.ErroredAt) + }) + }) +} + +// leftovers reports objects a previous crashed run may have left behind, +// so they don't accumulate unnoticed in a long-lived dev account. +func TestIntegration_ReportLeftovers(t *testing.T) { + c := integrationClient(t) + + page, err := c.Sites.List(ctx(), &firezone.SiteListOptions{ + ListOptions: firezone.ListOptions{Limit: 100}, + }) + if err != nil { + t.Fatalf("List: %v", err) + } + + var stale []string + for _, site := range page.Data { + if strings.HasPrefix(site.Name, namePrefix+"-") && !strings.Contains(site.Name, runID) { + stale = append(stale, site.Name) + } + } + if len(stale) > 0 { + t.Logf("%d Site(s) left by earlier runs (delete by the %q prefix): %s", + len(stale), namePrefix, strings.Join(stale, ", ")) + } +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index c8b96ca..1eecdd5 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -18,11 +18,20 @@ import ( // that). The server is closed automatically via t.Cleanup. func NewClient(t *testing.T, handler http.Handler) *firezone.Client { t.Helper() + return NewClientWithOptions(t, handler) +} + +// NewClientWithOptions is NewClient with extra options appended, for +// tests that need to configure the client under test. Retries stay +// disabled unless an option re-enables them. +func NewClientWithOptions(t *testing.T, handler http.Handler, opts ...firezone.Option) *firezone.Client { + t.Helper() server := httptest.NewServer(handler) t.Cleanup(server.Close) - client, err := firezone.NewClient(server.URL, "test-token", firezone.WithRetry(false, 0)) + client, err := firezone.NewClient(server.URL, "test-token", + append([]firezone.Option{firezone.WithRetry(false, 0)}, opts...)...) if err != nil { t.Fatalf("testutil: building client: %v", err) } diff --git a/memberships.go b/memberships.go index fce04f3..58a76e9 100644 --- a/memberships.go +++ b/memberships.go @@ -17,9 +17,16 @@ type MembershipsService struct { groupID string } +func (s *MembershipsService) basePath() string { + return buildPath("groups", s.groupID, "memberships") +} + // List returns a page of the Group's members. func (s *MembershipsService) List(ctx context.Context, opts *ListOptions) (*Page[GroupMember], error) { - return doList[GroupMember](ctx, s.client, "GET", "groups/"+s.groupID+"/memberships", listOptionsToQuery(opts)) + if err := checkID("Group ID", s.groupID); err != nil { + return nil, err + } + return doList[GroupMember](ctx, s.client, "GET", s.basePath(), listOptionsToQuery(opts)) } // membershipEntry is the wire shape of one entry in a ReplaceAll request. @@ -52,6 +59,9 @@ type membershipActorIDs struct { // deduplicated. The returned IDs are sorted, not echoed back in the // order they were sent. func (s *MembershipsService) ReplaceAll(ctx context.Context, actorIDs []string) ([]string, error) { + if err := checkID("Group ID", s.groupID); err != nil { + return nil, err + } entries := make([]membershipEntry, len(actorIDs)) for i, id := range actorIDs { entries[i] = membershipEntry{ActorID: id} @@ -61,7 +71,7 @@ func (s *MembershipsService) ReplaceAll(ctx context.Context, actorIDs []string) return nil, err } var result membershipActorIDs - if err := s.client.do(ctx, "PUT", "groups/"+s.groupID+"/memberships", nil, body, &result); err != nil { + if err := s.client.do(ctx, "PUT", s.basePath(), nil, body, &result); err != nil { return nil, err } return result.ActorIDs, nil @@ -79,12 +89,15 @@ func (s *MembershipsService) ReplaceAll(ctx context.Context, actorIDs []string) // member. Repeating an ID within either list is not an error; both are // deduplicated. The returned IDs are sorted. func (s *MembershipsService) Patch(ctx context.Context, add, remove []string) ([]string, error) { + if err := checkID("Group ID", s.groupID); err != nil { + return nil, err + } body, err := wrapBody("memberships", membershipPatchBody{Add: add, Remove: remove}) if err != nil { return nil, err } var result membershipActorIDs - if err := s.client.do(ctx, "PATCH", "groups/"+s.groupID+"/memberships", nil, body, &result); err != nil { + if err := s.client.do(ctx, "PATCH", s.basePath(), nil, body, &result); err != nil { return nil, err } return result.ActorIDs, nil diff --git a/mise.toml b/mise.toml index 07653ed..785217c 100644 --- a/mise.toml +++ b/mise.toml @@ -22,6 +22,7 @@ description = "go vet (CI check)" run = """ go vet ./... go vet -tags=integration ./... +go vet -tags=spec ./... """ [tasks.lint] @@ -34,7 +35,28 @@ run = "go test ./... -race -count=1" [tasks.test-acceptance] description = "Run integration tests against a real local dev server (requires FIREZONE_ENDPOINT/FIREZONE_TOKEN)" -run = "go test -tags=integration ./... -run Integration -v" +run = ''' +missing="" +[ -z "$FIREZONE_ENDPOINT" ] && missing="$missing FIREZONE_ENDPOINT" +[ -z "$FIREZONE_TOKEN" ] && missing="$missing FIREZONE_TOKEN" +if [ -n "$missing" ]; then + { + echo "acceptance tests need a portal to talk to; unset:$missing" + echo + echo ' cd /path/to/firezone/elixir && mix phx.server # in one terminal' + echo ' token=$(MIX_ENV=dev mix run --no-start script/seed_api_client_token.exs | tail -1)' + echo ' export FIREZONE_ENDPOINT=https://localhost:13001' + echo ' export FIREZONE_TOKEN="$token"' + echo ' export FIREZONE_CA_CERT=/path/to/firezone/elixir/priv/cert/selfsigned.pem' + echo + echo "Export these in the same shell you run this task from." + echo "See the README's Testing section." + } >&2 + exit 1 +fi +# -count=1 disables Go's test cache. +go test -tags=integration ./... -run Integration -v -count=1 +''' # The oldest Go this SDK claims to support, per the `go` directive in # go.mod. Kept here so `mise run test-floor` and CI use one source of @@ -51,6 +73,40 @@ GOTOOLCHAIN=$floor go vet ./... GOTOOLCHAIN=$floor go test ./... -count=1 """ +# Checks every JSON struct tag against Firezone's published OpenAPI +# spec. Unit tests can't catch a misspelled field - they assert against +# fixtures written by the same hand as the tag - so this is the only +# thing that verifies what the server actually sends. +# +# Runs against the spec vendored at testdata/openapi.json, so it needs +# no monorepo checkout and behaves identically here and in CI. Check +# against an unreleased spec with: +# FIREZONE_OPENAPI=/path/to/openapi.json mise run spec-check +[tasks.spec-check] +description = "Check struct tags against the vendored OpenAPI spec" +run = "go test -tags=spec ./... -run TestSpec -count=1 -v" + +# Refreshes the vendored spec from a local monorepo checkout. The +# resulting diff is the set of API changes this SDK hasn't accounted for +# yet, so it belongs in the same PR as the code that responds to it - +# never as a drive-by "update the fixture" commit to make CI green. +[tasks.spec-update] +description = "Refresh testdata/openapi.json from a firezone monorepo checkout (needs FIREZONE_MONOREPO)" +run = """ +if [ -z "$FIREZONE_MONOREPO" ]; then + echo "set FIREZONE_MONOREPO=/path/to/firezone (the monorepo checkout)" >&2 + exit 1 +fi +src="$FIREZONE_MONOREPO/elixir/priv/static/openapi.json" +if [ ! -f "$src" ]; then + echo "no spec at $src" >&2 + exit 1 +fi +cp "$src" testdata/openapi.json +echo "updated testdata/openapi.json from $src" +git --no-pager diff --stat -- testdata/openapi.json +""" + [tasks.vuln] description = "govulncheck (CI check)" run = "govulncheck ./..." @@ -61,4 +117,4 @@ run = "go build ./..." [tasks.check] description = "Run all CI static analysis + unit test checks (matches CI)" -depends = ["fmt-check", "vet", "lint", "test", "vuln", "build"] +depends = ["fmt-check", "vet", "lint", "test", "spec-check", "vuln", "build"] diff --git a/null.go b/null.go new file mode 100644 index 0000000..b76ad73 --- /dev/null +++ b/null.go @@ -0,0 +1,65 @@ +package firezone + +import "encoding/json" + +// Null holds an optional, nullable field in an update request. +// +// The API's update endpoints are merge-patch: a field absent from the +// request body keeps its current value, while an explicit JSON null +// clears it. A plain Go string can't express both - its zero value is +// indistinguishable from "not set" - so nullable update fields are +// typed *Null[T], which has three states: +// +// nil field omitted; the server keeps its current value +// Clear[T]() field sent as JSON null; the server clears it +// Set(v) field sent as v +// +// Set("") also clears a nullable string field, rather than storing an +// empty one: the API's changeset treats "" as an empty value and +// replaces it with the field's default, which for a nullable field is +// null. Prefer [Clear] anyway - it says what it means, works for +// non-string types, and doesn't depend on that coincidence holding. +type Null[T any] struct { + // Value is the value to send. Ignored unless Valid is true. + Value T + // Valid reports whether Value should be sent. When false, the field + // is sent as JSON null. + Valid bool +} + +// Set returns a *Null that sends v. +func Set[T any](v T) *Null[T] { return &Null[T]{Value: v, Valid: true} } + +// Clear returns a *Null that sends JSON null, clearing the field on the +// server. The type parameter is usually explicit, since there's no +// argument to infer it from: firezone.Clear[string](). +func Clear[T any]() *Null[T] { return &Null[T]{} } + +// MarshalJSON implements [json.Marshaler], encoding an invalid Null as +// JSON null and a valid one as its value. +func (n Null[T]) MarshalJSON() ([]byte, error) { + if !n.Valid { + return []byte("null"), nil + } + return json.Marshal(n.Value) +} + +// UnmarshalJSON implements [json.Unmarshaler]. A null decodes to the +// zero value with Valid false. +// +// Note that encoding/json sets a *Null field to nil on JSON null without +// calling this method, so decoding cannot tell a cleared field from an +// omitted one. Null is an encode-side type; the SDK never decodes a +// request body, and read models use plain fields. +func (n *Null[T]) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + var zero T + n.Value, n.Valid = zero, false + return nil + } + if err := json.Unmarshal(data, &n.Value); err != nil { + return err + } + n.Valid = true + return nil +} diff --git a/null_test.go b/null_test.go new file mode 100644 index 0000000..0566370 --- /dev/null +++ b/null_test.go @@ -0,0 +1,314 @@ +package firezone_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + firezone "github.com/firezone/firezone-go" + "github.com/firezone/firezone-go/internal/testutil" +) + +// captureBody runs call against a stub server and returns the exact +// bytes it sent. Asserting on the raw body (rather than a decoded map) +// is the whole point of these tests: a decoded map cannot tell an +// omitted field from one sent as JSON null, and that distinction is the +// difference between "leave this alone" and "clear it". +func captureBody(t *testing.T, response any, call func(c *firezone.Client) error) string { + t.Helper() + + var body string + client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("reading request body: %v", err) + } + body = string(b) + testutil.JSONResponse(http.StatusOK, map[string]any{"data": response})(w, r) + })) + + if err := call(client); err != nil { + t.Fatalf("request returned error: %v", err) + } + return body +} + +func TestNull_Marshal(t *testing.T) { + type wrapper struct { + Field *firezone.Null[string] `json:"field,omitempty"` + } + + tests := []struct { + name string + value *firezone.Null[string] + want string + }{ + {name: "nil pointer omits the field", value: nil, want: `{}`}, + {name: "Clear sends JSON null", value: firezone.Clear[string](), want: `{"field":null}`}, + {name: "Set sends the value", value: firezone.Set("x"), want: `{"field":"x"}`}, + { + // On the wire these stay distinct. The server happens to + // treat both as a clear (it replaces an empty string with + // the field's default), but that is its choice to make - the + // SDK's job is to send what the caller asked for. + name: "Set of the empty string sends an empty string, not null", + value: firezone.Set(""), + want: `{"field":""}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(wrapper{Field: tt.value}) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + if string(got) != tt.want { + t.Errorf("Marshal = %s, want %s", got, tt.want) + } + }) + } +} + +func TestNull_MarshalNamedStringType(t *testing.T) { + type wrapper struct { + Field *firezone.Null[firezone.IPStack] `json:"field,omitempty"` + } + + got, err := json.Marshal(wrapper{Field: firezone.Set(firezone.IPStackDual)}) + if err != nil { + t.Fatalf("Marshal returned error: %v", err) + } + if want := `{"field":"dual"}`; string(got) != want { + t.Errorf("Marshal = %s, want %s", got, want) + } +} + +func TestNull_Unmarshal(t *testing.T) { + type wrapper struct { + Field *firezone.Null[string] `json:"field,omitempty"` + } + + // A non-pointer Null does see the null, which is what UnmarshalJSON + // is there for. + t.Run("a non-pointer Null decodes null to an invalid Null", func(t *testing.T) { + var v struct { + Field firezone.Null[string] `json:"field"` + } + if err := json.Unmarshal([]byte(`{"field":null}`), &v); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if v.Field.Valid { + t.Errorf("Field.Valid = true, want false") + } + }) + + tests := []struct { + name string + in string + wantSet bool + wantValid bool + wantValue string + }{ + {name: "absent leaves the pointer nil", in: `{}`, wantSet: false}, + // encoding/json sets a pointer field to nil on JSON null without + // consulting the pointee's UnmarshalJSON, so a *Null cannot + // preserve the null/absent distinction when decoding. That only + // matters for round-tripping a request struct, which the SDK + // never does - see the Null doc comment. + {name: "null leaves the pointer nil", in: `{"field":null}`, wantSet: false}, + { + name: "a value decodes to a valid Null", in: `{"field":"x"}`, + wantSet: true, wantValid: true, wantValue: "x", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var w wrapper + if err := json.Unmarshal([]byte(tt.in), &w); err != nil { + t.Fatalf("Unmarshal returned error: %v", err) + } + if (w.Field != nil) != tt.wantSet { + t.Fatalf("Field != nil = %v, want %v", w.Field != nil, tt.wantSet) + } + if w.Field == nil { + return + } + if w.Field.Valid != tt.wantValid { + t.Errorf("Field.Valid = %v, want %v", w.Field.Valid, tt.wantValid) + } + if w.Field.Value != tt.wantValue { + t.Errorf("Field.Value = %q, want %q", w.Field.Value, tt.wantValue) + } + }) + } +} + +func TestUpdateResourceRequest_Body(t *testing.T) { + response := map[string]any{"id": "res-1", "name": "postgres", "type": "cidr"} + + tests := []struct { + name string + req *firezone.UpdateResourceRequest + want string + }{ + { + name: "omitted nullable fields are absent from the body", + req: &firezone.UpdateResourceRequest{Name: "renamed"}, + want: `{"resource":{"name":"renamed"}}`, + }, + { + name: "Clear sends null for each nullable field", + req: &firezone.UpdateResourceRequest{ + Address: firezone.Clear[string](), + AddressDescription: firezone.Clear[string](), + IPStack: firezone.Clear[firezone.IPStack](), + SiteID: firezone.Clear[string](), + }, + want: `{"resource":{"address":null,"address_description":null,"ip_stack":null,"site_id":null}}`, + }, + { + name: "Set sends each nullable field's value", + req: &firezone.UpdateResourceRequest{ + Address: firezone.Set("10.0.1.0/24"), + AddressDescription: firezone.Set("Production subnet"), + IPStack: firezone.Set(firezone.IPStackIPv4Only), + SiteID: firezone.Set("site-1"), + }, + want: `{"resource":{"address":"10.0.1.0/24","address_description":"Production subnet",` + + `"ip_stack":"ipv4_only","site_id":"site-1"}}`, + }, + { + name: "a nil Filters pointer leaves the filters alone", + req: &firezone.UpdateResourceRequest{Name: "renamed"}, + want: `{"resource":{"name":"renamed"}}`, + }, + { + name: "a pointer to an empty Filters slice clears every filter", + req: &firezone.UpdateResourceRequest{Filters: &[]firezone.Filter{}}, + want: `{"resource":{"filters":[]}}`, + }, + { + name: "a populated Filters pointer replaces the filters", + req: &firezone.UpdateResourceRequest{ + Filters: &[]firezone.Filter{ + {Protocol: firezone.FilterProtocolTCP, Ports: []string{"5432"}}, + }, + }, + want: `{"resource":{"filters":[{"protocol":"tcp","ports":["5432"]}]}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := captureBody(t, response, func(c *firezone.Client) error { + _, err := c.Resources.Update(context.Background(), "res-1", tt.req) + return err + }) + if got != tt.want { + t.Errorf("request body =\n\t%s\nwant\n\t%s", got, tt.want) + } + }) + } +} + +func TestUpdatePolicyRequest_Body(t *testing.T) { + response := map[string]any{"id": "pol-1", "group_id": "g", "resource_id": "r"} + + tests := []struct { + name string + req *firezone.UpdatePolicyRequest + want string + }{ + { + name: "an omitted Description is absent from the body", + req: &firezone.UpdatePolicyRequest{GroupID: "group-1"}, + want: `{"policy":{"group_id":"group-1"}}`, + }, + { + name: "Clear sends a null Description", + req: &firezone.UpdatePolicyRequest{Description: firezone.Clear[string]()}, + want: `{"policy":{"description":null}}`, + }, + { + name: "Set sends the Description", + req: &firezone.UpdatePolicyRequest{Description: firezone.Set("prod access")}, + want: `{"policy":{"description":"prod access"}}`, + }, + { + name: "a nil Conditions pointer leaves the conditions alone", + req: &firezone.UpdatePolicyRequest{GroupID: "group-1"}, + want: `{"policy":{"group_id":"group-1"}}`, + }, + { + name: "a pointer to an empty Conditions slice clears every condition", + req: &firezone.UpdatePolicyRequest{Conditions: &[]firezone.Condition{}}, + want: `{"policy":{"conditions":[]}}`, + }, + { + name: "a populated Conditions pointer replaces the conditions", + req: &firezone.UpdatePolicyRequest{ + Conditions: &[]firezone.Condition{{ + Property: firezone.ConditionPropertyRemoteIPLocationRegion, + Operator: firezone.ConditionOperatorIsIn, + Values: []string{"US"}, + }}, + }, + want: `{"policy":{"conditions":[{"property":"remote_ip_location_region",` + + `"operator":"is_in","values":["US"]}]}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := captureBody(t, response, func(c *firezone.Client) error { + _, err := c.Policies.Update(context.Background(), "pol-1", tt.req) + return err + }) + if got != tt.want { + t.Errorf("request body =\n\t%s\nwant\n\t%s", got, tt.want) + } + }) + } +} + +func TestUpdateActorRequest_Body(t *testing.T) { + response := map[string]any{"id": "actor-1", "name": "Alice", "type": "account_user"} + + tests := []struct { + name string + req *firezone.UpdateActorRequest + want string + }{ + { + name: "an omitted Email is absent from the body", + req: &firezone.UpdateActorRequest{Name: "Alice"}, + want: `{"actor":{"name":"Alice"}}`, + }, + { + name: "Clear sends a null Email", + req: &firezone.UpdateActorRequest{Email: firezone.Clear[string]()}, + want: `{"actor":{"email":null}}`, + }, + { + name: "Set sends the Email", + req: &firezone.UpdateActorRequest{Email: firezone.Set("alice@example.com")}, + want: `{"actor":{"email":"alice@example.com"}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := captureBody(t, response, func(c *firezone.Client) error { + _, err := c.Actors.Update(context.Background(), "actor-1", tt.req) + return err + }) + if got != tt.want { + t.Errorf("request body =\n\t%s\nwant\n\t%s", got, tt.want) + } + }) + } +} diff --git a/path.go b/path.go new file mode 100644 index 0000000..5b1a4b6 --- /dev/null +++ b/path.go @@ -0,0 +1,79 @@ +package firezone + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +// ErrMissingID is returned when a method is called with an empty or +// otherwise unusable resource ID. Test for it with [errors.Is]: +// +// if errors.Is(err, firezone.ErrMissingID) { ... } +// +// It is returned before any request is made, so an ID that came from +// unpopulated config fails loudly rather than being sent as an empty +// path segment - which would silently address the collection endpoint +// instead (GET /sites rather than GET /sites/{id}). +var ErrMissingID = errors.New("must not be empty") + +// checkID validates a caller-supplied path ID. name is the ID's role in +// the error message, e.g. "site ID". +// +// "." and ".." are rejected outright rather than escaped: url.PathEscape +// leaves them untouched (neither character is reserved), so they would +// reach the wire as-is and any path-normalizing proxy in front of the +// API would resolve them into a different path. Every other unsafe +// character, "/" included, is handled by escaping in buildPath. +func checkID(name, id string) error { + if id == "" { + return fmt.Errorf("firezone: %s %w", name, ErrMissingID) + } + if id == "." || id == ".." { + return fmt.Errorf("firezone: %s %q is not a valid path segment", name, id) + } + return nil +} + +// buildPath joins segments into a request path, percent-escaping each +// one so it stays a single path segment. +// +// Escaping is what keeps a caller-supplied ID from redirecting the +// request: without it an ID of "../actors/evil" turns +// GET /sites/{id} into GET /actors/evil, addressing a different +// resource entirely. Escaped, it stays one (nonexistent) segment and +// the API answers 404. +// +// The result is already escaped, so it goes into url.URL.RawPath rather +// than url.URL.Path - see resolvePath. +func buildPath(segments ...string) string { + escaped := make([]string, len(segments)) + for i, s := range segments { + escaped[i] = url.PathEscape(s) + } + return strings.Join(escaped, "/") +} + +// resolvePath resolves an escaped request path against the base URL, +// returning the absolute URL to request. +// +// It deliberately does not use path.Join: that operates on the decoded +// path and cleans "." and ".." segments, which would undo buildPath's +// escaping work by resolving traversal that the escaping was there to +// contain. Instead the escaped form is assembled directly and stored in +// RawPath, with the decoded form in Path - the pairing url.URL uses to +// preserve escaping through String(). +func resolvePath(base *url.URL, requestPath string) (*url.URL, error) { + escaped := strings.TrimSuffix(base.EscapedPath(), "/") + "/" + requestPath + + decoded, err := url.PathUnescape(escaped) + if err != nil { + return nil, fmt.Errorf("firezone: building request path: %w", err) + } + + u := *base + u.Path = decoded + u.RawPath = escaped + return &u, nil +} diff --git a/path_test.go b/path_test.go new file mode 100644 index 0000000..056fe7b --- /dev/null +++ b/path_test.go @@ -0,0 +1,316 @@ +package firezone_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + firezone "github.com/firezone/firezone-go" + "github.com/firezone/firezone-go/internal/testutil" +) + +// capturePath records the raw request target the client sent. It reads +// r.RequestURI rather than r.URL.Path because net/http decodes the +// latter - and the whole point of these tests is what stayed encoded. +func capturePath(t *testing.T, response any, call func(c *firezone.Client) error) string { + t.Helper() + + var target string + client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + target = r.RequestURI + testutil.JSONResponse(http.StatusOK, map[string]any{"data": response})(w, r) + })) + + if err := call(client); err != nil { + t.Fatalf("request returned error: %v", err) + } + return target +} + +func TestRequestPath_EscapesIDs(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {name: "a plain ID passes through", id: "site-1", want: "/sites/site-1"}, + { + // Unescaped, this would address GET /actors/evil - a + // different resource entirely. + name: "traversal stays inside one segment", + id: "../actors/evil", + want: "/sites/..%2Factors%2Fevil", + }, + {name: "a slash is escaped", id: "a/b", want: "/sites/a%2Fb"}, + {name: "a query separator is escaped", id: "a?limit=100", want: "/sites/a%3Flimit=100"}, + {name: "a fragment separator is escaped", id: "a#f", want: "/sites/a%23f"}, + {name: "a space is escaped", id: "a b", want: "/sites/a%20b"}, + {name: "a non-ASCII ID is escaped", id: "sité", want: "/sites/sit%C3%A9"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := capturePath(t, map[string]any{"id": tt.id}, func(c *firezone.Client) error { + _, err := c.Sites.Get(context.Background(), tt.id) + return err + }) + if got != tt.want { + t.Errorf("request target = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRequestPath_NestedAndNormalPathsAreUnchanged(t *testing.T) { + // List endpoints decode into a slice, single-resource endpoints into + // an object, so each case supplies the envelope payload its call + // expects. + tests := []struct { + name string + response any + call func(c *firezone.Client) error + want string + }{ + { + name: "nested gateway", + response: map[string]any{"id": "gw-1"}, + call: func(c *firezone.Client) error { + _, err := c.Sites.Gateways("site-1").Get(context.Background(), "gw-1") + return err + }, + want: "/sites/site-1/gateways/gw-1", + }, + { + name: "gateway token rotation", + response: map[string]any{"id": "tok-1", "token": "secret"}, + call: func(c *firezone.Client) error { + _, err := c.Sites.Gateways("site-1").RotateToken(context.Background(), "gw-1") + return err + }, + want: "/sites/site-1/gateways/gw-1/token/rotate", + }, + { + name: "group memberships", + response: []any{}, + call: func(c *firezone.Client) error { + _, err := c.Groups.Memberships("group-1").List(context.Background(), nil) + return err + }, + want: "/groups/group-1/memberships", + }, + { + name: "resource pool members", + response: []any{}, + call: func(c *firezone.Client) error { + _, err := c.Resources.PoolMembers("res-1").List(context.Background(), nil) + return err + }, + want: "/resources/res-1/pool_members", + }, + { + name: "client verify", + response: map[string]any{"id": "client-1"}, + call: func(c *firezone.Client) error { + _, err := c.ClientDevices.Verify(context.Background(), "client-1") + return err + }, + want: "/clients/client-1/verify", + }, + { + name: "a query is still appended", + response: []any{}, + call: func(c *firezone.Client) error { + _, err := c.Sites.List(context.Background(), &firezone.SiteListOptions{ + ListOptions: firezone.ListOptions{Limit: 10}, + }) + return err + }, + want: "/sites?limit=10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := capturePath(t, tt.response, tt.call) + if got != tt.want { + t.Errorf("request target = %q, want %q", got, tt.want) + } + }) + } +} + +// TestEmptyID_MakesNoRequest covers the failure an empty ID used to +// cause: "sites/" + "" collapses to the collection path, so Get("") +// became a list and Delete("") aimed at the collection endpoint. +func TestEmptyID_MakesNoRequest(t *testing.T) { + tests := []struct { + name string + call func(c *firezone.Client) error + }{ + {"Sites.Get", func(c *firezone.Client) error { _, err := c.Sites.Get(context.Background(), ""); return err }}, + {"Sites.Delete", func(c *firezone.Client) error { return c.Sites.Delete(context.Background(), "") }}, + {"Resources.Get", func(c *firezone.Client) error { + _, err := c.Resources.Get(context.Background(), "") + return err + }}, + {"Policies.Delete", func(c *firezone.Client) error { return c.Policies.Delete(context.Background(), "") }}, + {"Actors.Disable", func(c *firezone.Client) error { _, err := c.Actors.Disable(context.Background(), ""); return err }}, + {"ClientDevices.Verify", func(c *firezone.Client) error { + _, err := c.ClientDevices.Verify(context.Background(), "") + return err + }}, + {"Groups.Get", func(c *firezone.Client) error { _, err := c.Groups.Get(context.Background(), ""); return err }}, + {"OktaDirectories.Get", func(c *firezone.Client) error { + _, err := c.OktaDirectories.Get(context.Background(), "") + return err + }}, + {"Gateways.Get with an empty gateway ID", func(c *firezone.Client) error { + _, err := c.Sites.Gateways("site-1").Get(context.Background(), "") + return err + }}, + {"Gateways.Get with an empty site ID", func(c *firezone.Client) error { + _, err := c.Sites.Gateways("").Get(context.Background(), "gw-1") + return err + }}, + {"Gateways.List with an empty site ID", func(c *firezone.Client) error { + _, err := c.Sites.Gateways("").List(context.Background(), nil) + return err + }}, + {"Memberships.List with an empty group ID", func(c *firezone.Client) error { + _, err := c.Groups.Memberships("").List(context.Background(), nil) + return err + }}, + {"Memberships.ReplaceAll with an empty group ID", func(c *firezone.Client) error { + _, err := c.Groups.Memberships("").ReplaceAll(context.Background(), []string{"actor-1"}) + return err + }}, + {"PoolMembers.List with an empty resource ID", func(c *firezone.Client) error { + _, err := c.Resources.PoolMembers("").List(context.Background(), nil) + return err + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var called bool + client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + testutil.JSONResponse(http.StatusOK, map[string]any{"data": map[string]any{}})(w, r) + })) + + err := tt.call(client) + if !errors.Is(err, firezone.ErrMissingID) { + t.Errorf("error = %v, want one matching ErrMissingID", err) + } + if called { + t.Error("a request was sent; an empty ID must fail before reaching the network") + } + }) + } +} + +func TestDotID_MakesNoRequest(t *testing.T) { + // url.PathEscape leaves "." and ".." alone, so escaping alone can't + // contain them - a normalizing proxy would resolve them into a + // different path. They're rejected outright instead. + for _, id := range []string{".", ".."} { + t.Run(id, func(t *testing.T) { + var called bool + client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + testutil.JSONResponse(http.StatusOK, map[string]any{"data": map[string]any{}})(w, r) + })) + + _, err := client.Sites.Get(context.Background(), id) + if err == nil { + t.Fatal("Get returned no error, want a rejection") + } + if !strings.Contains(err.Error(), "not a valid path segment") { + t.Errorf("error = %v, want it to name the invalid segment", err) + } + if called { + t.Error("a request was sent; a dot segment must fail before reaching the network") + } + }) + } +} + +// TestBaseURLPathPrefixPreserved guards the note in the package doc: +// the API is unversioned today, but if a path prefix ever comes back, +// resolvePath is the one place that has to keep working. +func TestBaseURLPathPrefixPreserved(t *testing.T) { + var target string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + target = r.RequestURI + testutil.JSONResponse(http.StatusOK, map[string]any{"data": map[string]any{"id": "site-1"}})(w, r) + })) + defer server.Close() + + for _, base := range []string{server.URL + "/api/v1", server.URL + "/api/v1/"} { + client, err := firezone.NewClient(base, "test-token", firezone.WithRetry(false, 0)) + if err != nil { + t.Fatalf("NewClient(%q) returned error: %v", base, err) + } + if _, err := client.Sites.Get(context.Background(), "site-1"); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if want := "/api/v1/sites/site-1"; target != want { + t.Errorf("base %q: request target = %q, want %q", base, target, want) + } + } +} + +func TestNewClient_RejectsUnusableBaseURLs(t *testing.T) { + tests := []struct { + name string + baseURL string + wantErr string + }{ + { + // url.Parse reads this as a relative path with no scheme and + // no host, and returns no error. + name: "no scheme", baseURL: "api.firezone.dev", + wantErr: "scheme must be http or https", + }, + {name: "empty", baseURL: "", wantErr: "scheme must be http or https"}, + {name: "unsupported scheme", baseURL: "ftp://api.firezone.dev", wantErr: "scheme must be http or https"}, + {name: "scheme but no host", baseURL: "https://", wantErr: "missing host"}, + { + name: "query string", baseURL: "https://api.firezone.dev?limit=10", + wantErr: "must not include a query string", + }, + {name: "fragment", baseURL: "https://api.firezone.dev#x", wantErr: "must not include a fragment"}, + {name: "unparseable", baseURL: "https://api.firezone.dev/%zz", wantErr: "invalid base URL"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := firezone.NewClient(tt.baseURL, "token") + if err == nil { + t.Fatalf("NewClient(%q) returned no error, want one", tt.baseURL) + } + if client != nil { + t.Error("NewClient returned a non-nil client alongside an error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want it to contain %q", err, tt.wantErr) + } + }) + } +} + +func TestNewClient_AcceptsValidBaseURLs(t *testing.T) { + for _, base := range []string{ + "https://api.firezone.dev", + "https://api.firezone.dev/", + "http://localhost:13001", + "https://api.firezone.dev/api/v1", + } { + if _, err := firezone.NewClient(base, "token"); err != nil { + t.Errorf("NewClient(%q) returned error: %v", base, err) + } + } +} diff --git a/policies.go b/policies.go index 10b5362..b46bb82 100644 --- a/policies.go +++ b/policies.go @@ -93,12 +93,17 @@ type CreatePolicyRequest struct { // Setting IsDisabled to true stops the Policy granting access without // deleting it. type UpdatePolicyRequest struct { - GroupID string `json:"group_id,omitempty"` - ResourceID string `json:"resource_id,omitempty"` - Description string `json:"description,omitempty"` - FlowLogUploadsEnabled *bool `json:"flow_log_uploads_enabled,omitempty"` - IsDisabled *bool `json:"is_disabled,omitempty"` - Conditions []Condition `json:"conditions,omitempty"` + GroupID string `json:"group_id,omitempty"` + ResourceID string `json:"resource_id,omitempty"` + // Description is nullable, so it is typed [Null] - Clear[string]() + // removes it, and a nil pointer leaves it alone. + Description *Null[string] `json:"description,omitempty"` + FlowLogUploadsEnabled *bool `json:"flow_log_uploads_enabled,omitempty"` + IsDisabled *bool `json:"is_disabled,omitempty"` + // Conditions replaces the Policy's conditions wholesale. nil leaves + // them unchanged; a pointer to an empty slice removes all of them, + // making the Policy grant access unconditionally. + Conditions *[]Condition `json:"conditions,omitempty"` } // PoliciesService manages Policies. @@ -108,8 +113,11 @@ type PoliciesService struct { // Get fetches a single Policy by ID. func (s *PoliciesService) Get(ctx context.Context, id string) (*Policy, error) { + if err := checkID("Policy ID", id); err != nil { + return nil, err + } var policy Policy - if err := s.client.do(ctx, "GET", "policies/"+id, nil, nil, &policy); err != nil { + if err := s.client.do(ctx, "GET", buildPath("policies", id), nil, nil, &policy); err != nil { return nil, err } return &policy, nil @@ -152,12 +160,15 @@ func (s *PoliciesService) Create(ctx context.Context, req *CreatePolicyRequest) // Update updates a Policy. func (s *PoliciesService) Update(ctx context.Context, id string, req *UpdatePolicyRequest) (*Policy, error) { + if err := checkID("Policy ID", id); err != nil { + return nil, err + } body, err := wrapBody("policy", req) if err != nil { return nil, err } var policy Policy - if err := s.client.do(ctx, "PUT", "policies/"+id, nil, body, &policy); err != nil { + if err := s.client.do(ctx, "PATCH", buildPath("policies", id), nil, body, &policy); err != nil { return nil, err } return &policy, nil @@ -165,7 +176,10 @@ func (s *PoliciesService) Update(ctx context.Context, id string, req *UpdatePoli // Delete deletes a Policy. func (s *PoliciesService) Delete(ctx context.Context, id string) error { - return s.client.do(ctx, "DELETE", "policies/"+id, nil, nil, nil) + if err := checkID("Policy ID", id); err != nil { + return err + } + return s.client.do(ctx, "DELETE", buildPath("policies", id), nil, nil, nil) } // Disable disables a Policy, stopping it granting access without @@ -175,6 +189,9 @@ func (s *PoliciesService) Delete(ctx context.Context, id string) error { // This is a convenience wrapper over [PoliciesService.Update]; the API // has no dedicated disable endpoint. func (s *PoliciesService) Disable(ctx context.Context, id string) (*Policy, error) { + if err := checkID("Policy ID", id); err != nil { + return nil, err + } disabled := true return s.Update(ctx, id, &UpdatePolicyRequest{IsDisabled: &disabled}) } @@ -185,6 +202,9 @@ func (s *PoliciesService) Disable(ctx context.Context, id string) (*Policy, erro // This is a convenience wrapper over [PoliciesService.Update]; the API // has no dedicated enable endpoint. func (s *PoliciesService) Enable(ctx context.Context, id string) (*Policy, error) { + if err := checkID("Policy ID", id); err != nil { + return nil, err + } disabled := false return s.Update(ctx, id, &UpdatePolicyRequest{IsDisabled: &disabled}) } diff --git a/policies_test.go b/policies_test.go index 71dccfb..c12879c 100644 --- a/policies_test.go +++ b/policies_test.go @@ -132,8 +132,8 @@ func TestPoliciesService_DisableEnable(t *testing.T) { if err != nil { t.Fatalf("Disable returned error: %v", err) } - if gotMethod != http.MethodPut || gotPath != "/policies/pol-1" { - t.Errorf("request = %s %s, want PUT /policies/pol-1", gotMethod, gotPath) + if gotMethod != http.MethodPatch || gotPath != "/policies/pol-1" { + t.Errorf("request = %s %s, want PATCH /policies/pol-1", gotMethod, gotPath) } reqPolicy, ok := gotBody["policy"].(map[string]any) @@ -168,8 +168,8 @@ func TestPoliciesService_DisableEnable(t *testing.T) { if err != nil { t.Fatalf("Enable returned error: %v", err) } - if gotMethod != http.MethodPut || gotPath != "/policies/pol-1" { - t.Errorf("request = %s %s, want PUT /policies/pol-1", gotMethod, gotPath) + if gotMethod != http.MethodPatch || gotPath != "/policies/pol-1" { + t.Errorf("request = %s %s, want PATCH /policies/pol-1", gotMethod, gotPath) } reqPolicy, ok := gotBody["policy"].(map[string]any) diff --git a/pool_members.go b/pool_members.go index 2f6ed15..9974f6d 100644 --- a/pool_members.go +++ b/pool_members.go @@ -29,11 +29,14 @@ type PoolMembersService struct { } func (s *PoolMembersService) basePath() string { - return "resources/" + s.resourceID + "/pool_members" + return buildPath("resources", s.resourceID, "pool_members") } // List returns a page of the pool's member Clients. func (s *PoolMembersService) List(ctx context.Context, opts *ListOptions) (*Page[PoolMember], error) { + if err := checkID("Resource ID", s.resourceID); err != nil { + return nil, err + } return doList[PoolMember](ctx, s.client, "GET", s.basePath(), listOptionsToQuery(opts)) } @@ -65,6 +68,9 @@ type poolMemberDeviceIDs struct { // Client from another account, and a nonexistent ID all fail the same // way. func (s *PoolMembersService) ReplaceAll(ctx context.Context, deviceIDs []string) ([]string, error) { + if err := checkID("Resource ID", s.resourceID); err != nil { + return nil, err + } entries := make([]poolMemberEntry, len(deviceIDs)) for i, id := range deviceIDs { entries[i] = poolMemberEntry{DeviceID: id} @@ -89,6 +95,9 @@ func (s *PoolMembersService) ReplaceAll(ctx context.Context, deviceIDs []string) // and removing one that isn't are both no-ops. remove is applied before // add, so an ID in both slices ends up in the pool. func (s *PoolMembersService) Patch(ctx context.Context, add, remove []string) ([]string, error) { + if err := checkID("Resource ID", s.resourceID); err != nil { + return nil, err + } body, err := wrapBody("pool_members", poolMemberPatchBody{Add: add, Remove: remove}) if err != nil { return nil, err diff --git a/resources.go b/resources.go index 2ec0743..5205314 100644 --- a/resources.go +++ b/resources.go @@ -80,14 +80,28 @@ type CreateResourceRequest struct { // UpdateResourceRequest is the request body for [ResourcesService.Update]. // All fields are optional; omitted fields keep their current value. +// +// The nullable fields are typed [Null] so they can be cleared as well as +// set - see that type for the three states. Filters is a pointer to a +// slice for the same reason: a nil pointer leaves the Resource's filters +// alone, while a pointer to an empty slice removes all of them. type UpdateResourceRequest struct { - Name string `json:"name,omitempty"` - Type ResourceType `json:"type,omitempty"` - Address string `json:"address,omitempty"` - AddressDescription string `json:"address_description,omitempty"` - IPStack IPStack `json:"ip_stack,omitempty"` - SiteID string `json:"site_id,omitempty"` - Filters []Filter `json:"filters,omitempty"` + Name string `json:"name,omitempty"` + Type ResourceType `json:"type,omitempty"` + Address *Null[string] `json:"address,omitempty"` + // AddressDescription is free-form text describing the address. + // Clear[string]() removes it. Set("") removes it too - the API + // replaces an empty string with the field's default rather than + // storing it - but Clear states the intent. + AddressDescription *Null[string] `json:"address_description,omitempty"` + IPStack *Null[IPStack] `json:"ip_stack,omitempty"` + // SiteID moves the Resource to another Site. Clearing it detaches + // the Resource from its Site, which the API only permits for device + // pool Resources. + SiteID *Null[string] `json:"site_id,omitempty"` + // Filters replaces the Resource's filters wholesale. nil leaves them + // unchanged; a pointer to an empty slice removes all of them. + Filters *[]Filter `json:"filters,omitempty"` } // ResourcesService manages Resources, and, nested under them, static @@ -106,8 +120,11 @@ func (s *ResourcesService) PoolMembers(resourceID string) *PoolMembersService { // Get fetches a single Resource by ID. func (s *ResourcesService) Get(ctx context.Context, id string) (*Resource, error) { + if err := checkID("Resource ID", id); err != nil { + return nil, err + } var resource Resource - if err := s.client.do(ctx, "GET", "resources/"+id, nil, nil, &resource); err != nil { + if err := s.client.do(ctx, "GET", buildPath("resources", id), nil, nil, &resource); err != nil { return nil, err } return &resource, nil @@ -169,12 +186,15 @@ func (s *ResourcesService) Create(ctx context.Context, req *CreateResourceReques // pool's own type is not a change and is accepted, so a caller that // echoes the whole Resource back on update still works. func (s *ResourcesService) Update(ctx context.Context, id string, req *UpdateResourceRequest) (*Resource, error) { + if err := checkID("Resource ID", id); err != nil { + return nil, err + } body, err := wrapBody("resource", req) if err != nil { return nil, err } var resource Resource - if err := s.client.do(ctx, "PUT", "resources/"+id, nil, body, &resource); err != nil { + if err := s.client.do(ctx, "PATCH", buildPath("resources", id), nil, body, &resource); err != nil { return nil, err } return &resource, nil @@ -182,5 +202,8 @@ func (s *ResourcesService) Update(ctx context.Context, id string, req *UpdateRes // Delete deletes a Resource. func (s *ResourcesService) Delete(ctx context.Context, id string) error { - return s.client.do(ctx, "DELETE", "resources/"+id, nil, nil, nil) + if err := checkID("Resource ID", id); err != nil { + return err + } + return s.client.do(ctx, "DELETE", buildPath("resources", id), nil, nil, nil) } diff --git a/resources_test.go b/resources_test.go index b7675e9..19fc57b 100644 --- a/resources_test.go +++ b/resources_test.go @@ -63,6 +63,39 @@ func TestResourcesService_Create(t *testing.T) { }) } +func TestResourcesService_Update(t *testing.T) { + var gotMethod, gotPath string + var gotBody map[string]any + client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + decodeJSONBody(t, r, &gotBody) + testutil.JSONResponse(http.StatusOK, map[string]any{ + "data": map[string]any{"id": "res-1", "name": "renamed", "type": "cidr"}, + })(w, r) + })) + + resource, err := client.Resources.Update(context.Background(), "res-1", + &firezone.UpdateResourceRequest{Name: "renamed"}) + if err != nil { + t.Fatalf("Update returned error: %v", err) + } + if gotMethod != http.MethodPatch || gotPath != "/resources/res-1" { + t.Errorf("request = %s %s, want PATCH /resources/res-1", gotMethod, gotPath) + } + // A rename must not carry any other key: an update is a merge, and a + // stray zero value would overwrite a field the caller never named. + reqResource, ok := gotBody["resource"].(map[string]any) + if !ok { + t.Fatalf("body[\"resource\"] = %v, want an object", gotBody["resource"]) + } + if len(reqResource) != 1 || reqResource["name"] != "renamed" { + t.Errorf("body resource = %v, want only name=renamed", reqResource) + } + if resource.Name != "renamed" { + t.Errorf("resource.Name = %q, want renamed", resource.Name) + } +} + func TestResourcesService_Delete(t *testing.T) { client := testutil.NewClient(t, testutil.JSONResponse(http.StatusOK, map[string]any{ "data": map[string]any{"id": "res-1", "name": "postgres-prod"}, diff --git a/sites.go b/sites.go index a6fe62b..31d2f1d 100644 --- a/sites.go +++ b/sites.go @@ -34,8 +34,11 @@ func (s *SitesService) Gateways(siteID string) *GatewaysService { // Get fetches a single Site by ID. func (s *SitesService) Get(ctx context.Context, id string) (*Site, error) { + if err := checkID("site ID", id); err != nil { + return nil, err + } var site Site - if err := s.client.do(ctx, "GET", "sites/"+id, nil, nil, &site); err != nil { + if err := s.client.do(ctx, "GET", buildPath("sites", id), nil, nil, &site); err != nil { return nil, err } return &site, nil @@ -73,12 +76,15 @@ func (s *SitesService) Create(ctx context.Context, req *CreateSiteRequest) (*Sit // Update updates a Site. func (s *SitesService) Update(ctx context.Context, id string, req *UpdateSiteRequest) (*Site, error) { + if err := checkID("site ID", id); err != nil { + return nil, err + } body, err := wrapBody("site", req) if err != nil { return nil, err } var site Site - if err := s.client.do(ctx, "PUT", "sites/"+id, nil, body, &site); err != nil { + if err := s.client.do(ctx, "PATCH", buildPath("sites", id), nil, body, &site); err != nil { return nil, err } return &site, nil @@ -86,5 +92,8 @@ func (s *SitesService) Update(ctx context.Context, id string, req *UpdateSiteReq // Delete deletes a Site. func (s *SitesService) Delete(ctx context.Context, id string) error { - return s.client.do(ctx, "DELETE", "sites/"+id, nil, nil, nil) + if err := checkID("site ID", id); err != nil { + return err + } + return s.client.do(ctx, "DELETE", buildPath("sites", id), nil, nil, nil) } diff --git a/sites_test.go b/sites_test.go index 3f89a2e..d86bd3c 100644 --- a/sites_test.go +++ b/sites_test.go @@ -220,8 +220,8 @@ func TestSitesService_Update(t *testing.T) { if err != nil { t.Fatalf("Update returned error: %v", err) } - if gotMethod != http.MethodPut || gotPath != "/sites/site-1" { - t.Errorf("request = %s %s, want PUT /sites/site-1", gotMethod, gotPath) + if gotMethod != http.MethodPatch || gotPath != "/sites/site-1" { + t.Errorf("request = %s %s, want PATCH /sites/site-1", gotMethod, gotPath) } if site.Name != "renamed" { t.Errorf("site.Name = %q, want renamed", site.Name) diff --git a/spec_test.go b/spec_test.go new file mode 100644 index 0000000..63f515a --- /dev/null +++ b/spec_test.go @@ -0,0 +1,839 @@ +//go:build spec + +// Package firezone's OpenAPI conformance check. +// +// Every field this SDK decodes is an assumption about what the server +// sends. Unit tests can't test those assumptions - they assert against +// fixtures written by the same person who wrote the struct tag, so a +// misspelled field passes every test and silently decodes as a zero +// value forever. This test checks the struct tags against Firezone's +// published OpenAPI spec instead, which is the only thing that actually +// knows. +// +// Run it with `mise run spec-check`. It checks against the spec vendored +// at testdata/openapi.json; point it at another copy with +// +// FIREZONE_OPENAPI=/path/to/openapi.json mise run spec-check +// +// to check against an unreleased spec in a local monorepo checkout. +package firezone + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "go/types" + "os" + "reflect" + "sort" + "strconv" + "strings" + "testing" +) + +// schemaFor maps an SDK type to its OpenAPI schema when the names +// differ. Types not listed here are matched by identical name. +// +// A value may name a property inside a schema with a dotted path, which +// is how the response envelopes are reached: the payload this SDK +// decodes is the "data" property of a *Response schema, not a schema of +// its own. +var schemaFor = map[string]string{ + "ClientDevice": "Client", + // Not the bare Gateway schema: the provision response is a Gateway + // plus the one-time token, which the bare schema does not carry. + "ProvisionedGateway": "GatewayProvisionResponse.data", + "Condition": "PolicyCondition", + "Filter": "ResourceFilter", + "GroupMember": "Membership", + "RotatedGatewayToken": "GatewayTokenResponse.data", + + // Unexported types decode server responses too, and get no less + // scrutiny for being lowercase - a typo in one of these is the same + // silent zero value as in any exported read model. + "membershipActorIDs": "MembershipResponse.data", + "poolMemberDeviceIDs": "PoolMemberResponse.data", + "pageMetadataBody": "PaginationMetadata", + "problemDetailsBody": "ValidationProblemDetails", +} + +// skipTypes are SDK types with no OpenAPI counterpart, each with the +// reason. A type that is neither here nor resolvable to a schema fails +// the test - that is deliberate, so a new resource can't be added +// without someone deciding which bucket it belongs in. +var skipTypes = map[string]string{ + "AuthProvider": "embedded base type; its fields are checked via each concrete provider", + // The envelopes are the wrapper the spec describes per response + // schema rather than as a schema of their own; every *Response + // schema's "data"/"metadata" pair is what they mirror. + "dataEnvelope": "generic {\"data\": ...} wrapper, not a schema in its own right", + "listEnvelope": "generic {\"data\": ..., \"metadata\": ...} wrapper, not a schema in its own right", +} + +// TestSpecConformance fails when the SDK declares a JSON field the +// server never sends. Fields the server sends that the SDK omits are +// reported but not fatal: not exposing a field is a deliberate scope +// choice, while decoding one that doesn't exist is always a bug. +func TestSpecConformance(t *testing.T) { + specPath := findSpec(t) + schemas := loadSchemas(t, specPath) + structs := parseStructs(t) + + var missingSchema []string + + for _, name := range sortedKeys(structs) { + if reason, ok := skipTypes[name]; ok { + t.Logf("skip %s: %s", name, reason) + continue + } + if !isReadModel(name) { + continue + } + // Types with no JSON tags decode nothing - services, the client + // itself, errors built by hand. There is no assumption to check. + if len(structs[name]) == 0 { + continue + } + + schemaName := name + if alias, ok := schemaFor[name]; ok { + schemaName = alias + } + props, ok := schemas[schemaName] + if !ok { + missingSchema = append(missingSchema, name) + continue + } + + fields := structs[name] + var ghosts []string + for _, f := range fields { + if _, ok := props[f]; !ok { + ghosts = append(ghosts, f) + } + } + if len(ghosts) > 0 { + sort.Strings(ghosts) + t.Errorf("%s (schema %q): declares %d field(s) absent from the spec, which will always decode as zero: %s", + name, schemaName, len(ghosts), strings.Join(ghosts, ", ")) + } + + // Informational: spec fields this type doesn't expose. + var uncovered []string + for p := range props { + if !contains(fields, p) { + uncovered = append(uncovered, p) + } + } + if len(uncovered) > 0 { + sort.Strings(uncovered) + t.Logf("%s: %d spec field(s) not exposed by the SDK: %s", + name, len(uncovered), strings.Join(uncovered, ", ")) + } + } + + if len(missingSchema) > 0 { + sort.Strings(missingSchema) + t.Errorf("no OpenAPI schema found for: %s\n"+ + "Add each to schemaFor (if the schema is named differently) or to skipTypes (with a reason).", + strings.Join(missingSchema, ", ")) + } +} + +// vendoredSpec is the checked-in copy of the OpenAPI document. Having +// it in the repo is what lets these checks run in CI: before it was +// vendored they searched for a sibling monorepo checkout and skipped +// when they found none, so the one test that can catch a wrong struct +// tag never ran anywhere except on the machine of someone who happened +// to have both repos cloned. +const vendoredSpec = "testdata/openapi.json" + +// findSpec locates the OpenAPI document: the vendored copy by default, +// or an explicit override for checking against an unreleased spec in a +// local monorepo checkout. +// +// The default is the vendored copy rather than a discovered checkout so +// that a local run and a CI run check against the same bytes. Refreshing +// the vendored spec is deliberate - see testdata/README.md. +func findSpec(t *testing.T) string { + path := vendoredSpec + if p := os.Getenv("FIREZONE_OPENAPI"); p != "" { + path = p + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("OpenAPI spec %s: %v\n"+ + "The spec is vendored at %s; restore it, or point FIREZONE_OPENAPI at a copy.", + path, err, vendoredSpec) + } + return path +} + +// loadSchemas reads components.schemas into schema name -> property set. +// +// Names may carry a dotted suffix ("MembershipResponse.data") selecting +// a property's own schema, which is how a payload the spec only +// describes inside a response envelope becomes checkable. +func loadSchemas(t *testing.T, path string) map[string]map[string]struct{} { + raw := loadRawSchemas(t, path) + + out := make(map[string]map[string]struct{}, len(raw.all)) + for name, schema := range raw.all { + out[name] = propertyNames(raw, schema) + for prop, sub := range schema.Properties { + resolved := raw.resolve(sub, 0) + if len(resolved.Properties) > 0 || len(resolved.AllOf) > 0 { + out[name+"."+prop] = propertyNames(raw, resolved) + } + } + } + t.Logf("loaded %d schemas from %s", len(raw.all), path) + return out +} + +func propertyNames(raw rawSpec, schema *rawSchema) map[string]struct{} { + props := make(map[string]struct{}, len(schema.Properties)) + for p := range schema.Properties { + props[p] = struct{}{} + } + for _, branch := range schema.AllOf { + for p := range propertyNames(raw, raw.resolve(branch, 0)) { + props[p] = struct{}{} + } + } + return props +} + +// forEachStruct walks every struct type declared in the package's +// non-test files, so the two struct walks share one parse. +func forEachStruct(t *testing.T, fn func(name string, st *ast.StructType)) { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool { + return strings.HasSuffix(fi.Name(), ".go") && !strings.HasSuffix(fi.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatalf("parse package: %v", err) + } + for _, pkg := range pkgs { + for _, file := range pkg.Files { + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + if st, ok := ts.Type.(*ast.StructType); ok { + fn(ts.Name.Name, st) + } + return true + }) + } + } +} + +// parseStructs returns every struct in the package as its list of JSON +// field names, with embedded structs flattened in - without that, a type +// that embeds a shared base looks like it is missing all of the base's +// fields. +func parseStructs(t *testing.T) map[string][]string { + own := map[string][]string{} // type -> its own json tags + embeds := map[string][]string{} // type -> embedded type names + + forEachStruct(t, func(name string, st *ast.StructType) { + if _, seen := own[name]; !seen { + own[name] = nil + } + for _, f := range st.Fields.List { + // An embedded field has no names. + if len(f.Names) == 0 { + if id, ok := f.Type.(*ast.Ident); ok { + embeds[name] = append(embeds[name], id.Name) + } + continue + } + if f.Tag == nil { + continue + } + tag := reflect.StructTag(strings.Trim(f.Tag.Value, "`")) + jt := tag.Get("json") + if jt == "" || jt == "-" { + continue + } + if fname := strings.Split(jt, ",")[0]; fname != "" { + own[name] = append(own[name], fname) + } + } + }) + + // Flatten embedded types (depth-limited; the SDK nests one level). + resolved := make(map[string][]string, len(own)) + var expand func(string, int) []string + expand = func(name string, depth int) []string { + if depth > 8 { + t.Fatalf("embedding cycle at %s", name) + } + fields := append([]string(nil), own[name]...) + for _, e := range embeds[name] { + if _, ok := own[e]; ok { + fields = append(fields, expand(e, depth+1)...) + } + } + return fields + } + for name := range own { + resolved[name] = expand(name, 0) + } + t.Logf("parsed %d struct types from package source", len(resolved)) + return resolved +} + +// isReadModel reports whether a type decodes a server response. +// +// Request bodies are excluded by their presence in requestSchemaFor +// rather than by a naming rule, so an unexported body type can't slip +// past both walks by not looking like a request. Option structs never +// reach the wire as JSON at all. +func isReadModel(name string) bool { + if name == "" { + return false + } + if _, ok := requestSchemaFor[name]; ok { + return false + } + return !strings.HasSuffix(name, "Options") +} + +func contains(hay []string, needle string) bool { + for _, h := range hay { + if h == needle { + return true + } + } + return false +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// requestSpec locates an SDK request body in the OpenAPI document and +// records how the endpoint treats it. +type requestSpec struct { + // schema is the OpenAPI request schema name. + schema string + // merge marks an endpoint that merges the body into the existing + // record, so an omitted field and a field sent as null mean + // different things. Only these need [firezone.Null] typing; a create + // body has nothing to clear, and a full-replace body sends the whole + // collection every time. + merge bool +} + +// requestSchemaFor maps every SDK request body to its OpenAPI schema. +// A request type absent from this table fails TestSpecRequestConformance, +// so no request body can be added without someone deciding which schema +// it answers to. +var requestSchemaFor = map[string]requestSpec{ + "CreateActorRequest": {schema: "ActorCreateRequest"}, + "UpdateActorRequest": {schema: "ActorUpdateRequest", merge: true}, + "UpdateClientRequest": {schema: "ClientPutRequest", merge: true}, + "ProvisionGatewayRequest": {schema: "GatewayCreateRequest"}, + "UpdateGatewayRequest": {schema: "GatewayUpdateRequest", merge: true}, + "CreateGroupRequest": {schema: "GroupCreateRequest"}, + "UpdateGroupRequest": {schema: "GroupUpdateRequest", merge: true}, + "membershipEntry": {schema: "MembershipPutRequest"}, + "membershipPatchBody": {schema: "MembershipPatchRequest"}, + "CreatePolicyRequest": {schema: "PolicyCreateRequest"}, + "UpdatePolicyRequest": {schema: "PolicyUpdateRequest", merge: true}, + "poolMemberEntry": {schema: "PoolMemberPutRequest"}, + "poolMemberPatchBody": {schema: "PoolMemberPatchRequest"}, + "CreateResourceRequest": {schema: "ResourceCreateRequest"}, + "UpdateResourceRequest": {schema: "ResourceUpdateRequest", merge: true}, + "CreateSiteRequest": {schema: "SiteCreateRequest"}, + "UpdateSiteRequest": {schema: "SiteUpdateRequest", merge: true}, +} + +// TestSpecRequestConformance checks every request body this SDK sends +// against the schema the server validates it with. +// +// The read-model check (TestSpecConformance) covers the decode side. The +// encode side is the half that fails silently: the server casts a +// request body with Ecto's changeset cast, which drops keys it doesn't +// recognise without comment - so a misspelled tag on a create body +// returns 201 Created with the field quietly missing, and every unit +// test still passes because the fixtures were written by the same hand +// as the tag. +// +// Four rules, in increasing specificity: +// +// - Every field the SDK sends must exist in the schema (otherwise the +// server drops it). +// - Every field the spec marks required must be present and sent +// unconditionally (otherwise the call 422s). +// - On a merge-patch body, a nullable field must be *Null[T] so it can +// be cleared as well as set. +// - On a merge-patch body, an array field must be a pointer to a slice +// so an empty list can be sent at all. +func TestSpecRequestConformance(t *testing.T) { + specPath := findSpec(t) + schemas := loadRequestSchemas(t, specPath) + fields := parseStructFieldTypes(t) + + for _, name := range sortedKeys(fields) { + req, ok := requestSchemaFor[name] + if !ok { + continue + } + schema, ok := schemas[req.schema] + if !ok { + t.Errorf("%s: OpenAPI schema %q not found", name, req.schema) + continue + } + + declared := map[string]structField{} + for _, f := range fields[name] { + declared[f.jsonName] = f + + prop, ok := schema.props[f.jsonName] + if !ok { + t.Errorf("%s.%s (json %q): absent from schema %q; the server drops unrecognised keys, "+ + "so this field would be silently discarded", + name, f.goName, f.jsonName, req.schema) + continue + } + if !req.merge { + continue + } + switch { + case prop.Nullable && !strings.HasPrefix(f.goType, "*Null["): + t.Errorf("%s.%s (json %q): spec marks it nullable, so it must be *Null[T] to be clearable; got %s", + name, f.goName, f.jsonName, f.goType) + case prop.Type == "array" && !strings.HasPrefix(f.goType, "*[]"): + t.Errorf("%s.%s (json %q): spec type is array, so it must be *[]T for an empty list to be sendable; got %s", + name, f.goName, f.jsonName, f.goType) + } + } + + for _, want := range schema.required { + f, ok := declared[want] + if !ok { + t.Errorf("%s: schema %q requires %q, which this type cannot send", + name, req.schema, want) + continue + } + if f.omitEmpty { + t.Errorf("%s.%s (json %q): schema %q requires it, so it must not be omitempty - "+ + "a zero value would drop it and the call would 422", + name, f.goName, f.jsonName, req.schema) + } + } + } +} + +// TestSpecRequestWrapperKeys checks the key each request body is nested +// under against the wrapper property the spec declares. +// +// The key is a bare string at the wrapBody call site rather than part of +// the request type, so it is invisible to the checks above. A wrong one +// is a 400 on every call to that endpoint - loud, but only once someone +// runs it against a real server, which no test here does. +func TestSpecRequestWrapperKeys(t *testing.T) { + specPath := findSpec(t) + schemas := loadRequestSchemas(t, specPath) + + calls := parseWrapBodyCalls(t) + if len(calls) == 0 { + t.Fatal("found no wrapBody calls to check; the AST walk is broken, not the code") + } + + for _, c := range calls { + req, ok := requestSchemaFor[c.requestType] + if !ok { + t.Errorf("%s: wrapBody(%q, ...) wraps %s, which is absent from requestSchemaFor", + c.pos, c.key, c.requestType) + continue + } + schema := schemas[req.schema] + if c.key != schema.wrapper { + t.Errorf("%s: wrapBody(%q, ...) wraps %s, but schema %q nests the body under %q", + c.pos, c.key, c.requestType, req.schema, schema.wrapper) + } + } + t.Logf("checked %d wrapBody call sites", len(calls)) +} + +// rawSchema is the subset of an OpenAPI schema these checks read. +type rawSchema struct { + Ref string `json:"$ref"` + Type string `json:"type"` + Nullable bool `json:"nullable"` + Required []string `json:"required"` + // AllOf composes a schema from several others. The spec uses it in + // exactly one place - the provisioned-gateway payload, which is a + // Gateway plus the one-time token - but a composed schema whose + // branches went unread would look like it had no fields at all. + AllOf []*rawSchema `json:"allOf"` + Items *rawSchema `json:"items"` + Properties map[string]*rawSchema `json:"properties"` +} + +// rawSpec is the parsed document plus the $ref resolution it needs. +type rawSpec struct { + all map[string]*rawSchema +} + +func (r rawSpec) resolve(s *rawSchema, depth int) *rawSchema { + if s == nil { + return &rawSchema{} + } + if s.Ref == "" || depth > 8 { + return s + } + target, ok := r.all[strings.TrimPrefix(s.Ref, "#/components/schemas/")] + if !ok { + return s + } + return r.resolve(target, depth+1) +} + +func loadRawSchemas(t *testing.T, path string) rawSpec { + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read spec: %v", err) + } + var doc struct { + Components struct { + Schemas map[string]*rawSchema `json:"schemas"` + } `json:"components"` + } + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatalf("parse spec: %v", err) + } + if len(doc.Components.Schemas) == 0 { + t.Fatalf("spec %s has no components.schemas", path) + } + return rawSpec{all: doc.Components.Schemas} +} + +// specProperty is the part of an OpenAPI property these checks read. +type specProperty struct { + Type string + Nullable bool +} + +// requestSchema is one request body's shape: the key it is nested under, +// its fields, and which of them the server requires. +type requestSchema struct { + wrapper string + props map[string]specProperty + required []string +} + +// loadRequestSchemas reads each request schema and unwraps the single +// property the API nests bodies under (e.g. {"resource": {...}}). +// +// A wrapper holding an array (the membership and pool member PUT +// bodies) is unwrapped one step further, to the element schema - that is +// the shape the SDK's Go type describes. +func loadRequestSchemas(t *testing.T, path string) map[string]requestSchema { + raw := loadRawSchemas(t, path) + + out := map[string]requestSchema{} + for name, schema := range raw.all { + if len(schema.Properties) != 1 { + continue + } + var wrapper string + var inner *rawSchema + for k, v := range schema.Properties { + wrapper, inner = k, raw.resolve(v, 0) + } + if inner.Type == "array" { + inner = raw.resolve(inner.Items, 0) + } + if len(inner.Properties) == 0 { + continue + } + + props := make(map[string]specProperty, len(inner.Properties)) + for f, v := range inner.Properties { + resolved := raw.resolve(v, 0) + props[f] = specProperty{Type: resolved.Type, Nullable: resolved.Nullable || v.Nullable} + } + out[name] = requestSchema{wrapper: wrapper, props: props, required: inner.Required} + } + return out +} + +// structField is one JSON-tagged field, with the Go type it is declared +// as - the tag alone can't tell whether a nullable field is reachable. +type structField struct { + goName string + goType string + jsonName string + omitEmpty bool +} + +// parseStructFieldTypes returns each struct's JSON-tagged fields along +// with their Go types. parseStructs deliberately returns only tag names +// (it checks a different property); this keeps the type information that +// nullability needs. +func parseStructFieldTypes(t *testing.T) map[string][]structField { + out := map[string][]structField{} + forEachStruct(t, func(name string, st *ast.StructType) { + for _, f := range st.Fields.List { + if len(f.Names) == 0 || f.Tag == nil { + continue + } + jt := reflect.StructTag(strings.Trim(f.Tag.Value, "`")).Get("json") + if jt == "" || jt == "-" { + continue + } + parts := strings.Split(jt, ",") + if parts[0] == "" { + continue + } + out[name] = append(out[name], structField{ + goName: f.Names[0].Name, + goType: types.ExprString(f.Type), + jsonName: parts[0], + omitEmpty: contains(parts[1:], "omitempty"), + }) + } + }) + return out +} + +// wrapBodyCall is one wrapBody call site: the key it nests the body +// under, and the request type it wraps. +type wrapBodyCall struct { + key string + requestType string + pos string +} + +// parseWrapBodyCalls finds every wrapBody call and works out which +// request type it wraps, from the AST alone. +// +// The second argument takes three shapes across the call sites: a +// parameter (wrapBody(key, req)), a composite literal +// (wrapBody(key, membershipPatchBody{...})), and a slice built earlier +// in the function (entries := make([]membershipEntry, ...)). An argument +// in none of those shapes fails the test rather than being skipped, so +// a new call site can't quietly go unchecked. +func parseWrapBodyCalls(t *testing.T) []wrapBodyCall { + fset := token.NewFileSet() + pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool { + return strings.HasSuffix(fi.Name(), ".go") && !strings.HasSuffix(fi.Name(), "_test.go") + }, 0) + if err != nil { + t.Fatalf("parse package: %v", err) + } + + var calls []wrapBodyCall + for _, pkg := range pkgs { + for _, file := range pkg.Files { + ast.Inspect(file, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok || fn.Body == nil { + return true + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if id, ok := call.Fun.(*ast.Ident); !ok || id.Name != "wrapBody" || len(call.Args) != 2 { + return true + } + + pos := fset.Position(call.Pos()).String() + key, ok := stringLit(call.Args[0]) + if !ok { + t.Errorf("%s: wrapBody's key is not a string literal; this check can't verify it", pos) + return true + } + typ, ok := wrappedType(fn, call.Args[1]) + if !ok { + t.Errorf("%s: cannot determine the type wrapBody(%q, ...) wraps", pos, key) + return true + } + calls = append(calls, wrapBodyCall{key: key, requestType: typ, pos: pos}) + return true + }) + return false + }) + } + } + return calls +} + +// wrappedType names the request type an expression carries, stripping +// pointer and slice decoration to the underlying struct name. +func wrappedType(fn *ast.FuncDecl, arg ast.Expr) (string, bool) { + switch a := arg.(type) { + case *ast.CompositeLit: + return baseTypeName(a.Type) + case *ast.Ident: + // A parameter carries its type in the signature. + for _, p := range fn.Type.Params.List { + for _, n := range p.Names { + if n.Name == a.Name { + return baseTypeName(p.Type) + } + } + } + // Otherwise it was built in the body: entries := make([]T, ...). + var found string + ast.Inspect(fn.Body, func(n ast.Node) bool { + assign, ok := n.(*ast.AssignStmt) + if !ok || len(assign.Lhs) != 1 || len(assign.Rhs) != 1 { + return true + } + if id, ok := assign.Lhs[0].(*ast.Ident); !ok || id.Name != a.Name { + return true + } + call, ok := assign.Rhs[0].(*ast.CallExpr) + if !ok || len(call.Args) == 0 { + return true + } + if id, ok := call.Fun.(*ast.Ident); !ok || id.Name != "make" { + return true + } + if name, ok := baseTypeName(call.Args[0]); ok { + found = name + } + return true + }) + return found, found != "" + } + return "", false +} + +// baseTypeName strips *, [] and package qualifiers down to a type name. +func baseTypeName(e ast.Expr) (string, bool) { + switch t := e.(type) { + case *ast.Ident: + return t.Name, true + case *ast.StarExpr: + return baseTypeName(t.X) + case *ast.ArrayType: + return baseTypeName(t.Elt) + case *ast.SelectorExpr: + return t.Sel.Name, true + } + return "", false +} + +func stringLit(e ast.Expr) (string, bool) { + lit, ok := e.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return "", false + } + v, err := strconv.Unquote(lit.Value) + return v, err == nil +} + +// TestSpecReadModelNullability checks that a read-model field the spec +// marks nullable can actually represent null. +// +// String fields are exempt: decoding null to "" is idiomatic Go and +// loses nothing anyone acts on. Numbers and booleans are not, because +// their zero values are legitimate values - a null session lifetime +// decoding to 0 reads as "sessions expire immediately" rather than "not +// configured", which is how this rule came to exist. The acceptance +// tests found that one against a real portal; this catches the next one +// without needing a live server. +// +// Embedded fields are flattened in, so a field on a shared base type is +// checked against every concrete schema that carries it. That matters +// here: the spec marks the session lifetimes nullable on only one of the +// five auth providers even though the underlying column is identical for +// all of them, so the rule has to fire on any schema that admits null. +func TestSpecReadModelNullability(t *testing.T) { + specPath := findSpec(t) + raw := loadRawSchemas(t, specPath) + fields := flattenedFieldTypes(t) + + for _, name := range sortedKeys(fields) { + if _, skipped := skipTypes[name]; skipped { + continue + } + if !isReadModel(name) { + continue + } + schemaName := name + if alias, ok := schemaFor[name]; ok { + schemaName = alias + } + schema, ok := raw.all[schemaName] + if !ok { + // TestSpecConformance already reports an unmapped read + // model; no need to say it twice. + continue + } + + for _, f := range fields[name] { + prop, ok := schema.Properties[f.jsonName] + if !ok { + continue + } + resolved := raw.resolve(prop, 0) + nullable := resolved.Nullable || prop.Nullable + switch { + case !nullable, + resolved.Type == "string", + resolved.Type == "array", + strings.HasPrefix(f.goType, "*"), + strings.HasPrefix(f.goType, "[]"): + continue + } + t.Errorf("%s.%s (json %q): spec marks it nullable and its type is %s, "+ + "so null would decode to the zero value and be indistinguishable from a real one; "+ + "use *%s", + name, f.goName, f.jsonName, resolved.Type, f.goType) + } + } +} + +// flattenedFieldTypes returns each struct's JSON-tagged fields with +// their Go types, with embedded structs' fields folded into the types +// that embed them. +func flattenedFieldTypes(t *testing.T) map[string][]structField { + own := parseStructFieldTypes(t) + embeds := map[string][]string{} + + forEachStruct(t, func(name string, st *ast.StructType) { + for _, f := range st.Fields.List { + if len(f.Names) != 0 { + continue + } + if id, ok := f.Type.(*ast.Ident); ok { + embeds[name] = append(embeds[name], id.Name) + } + } + }) + + out := make(map[string][]structField, len(own)) + var expand func(string, int) []structField + expand = func(name string, depth int) []structField { + if depth > 8 { + t.Fatalf("embedding cycle at %s", name) + } + fields := append([]structField(nil), own[name]...) + for _, e := range embeds[name] { + if _, ok := own[e]; ok { + fields = append(fields, expand(e, depth+1)...) + } + } + return fields + } + for name := range own { + out[name] = expand(name, 0) + } + return out +} diff --git a/testdata/README.md b/testdata/README.md new file mode 100644 index 0000000..045d8e2 --- /dev/null +++ b/testdata/README.md @@ -0,0 +1,25 @@ +# testdata + +## `openapi.json` + +A verbatim copy of Firezone's published OpenAPI document, vendored so +the spec checks in `spec_test.go` run in CI without needing a monorepo +checkout alongside this one. + +- **Source:** `elixir/priv/static/openapi.json` in the + [`firezone/firezone`](https://github.com/firezone/firezone) monorepo +- **API version:** 1.0.0 (OpenAPI 3.0.0) + +Refresh it against a local monorepo checkout with: + +```bash +FIREZONE_MONOREPO=/path/to/firezone mise run spec-update +``` + +Copy it verbatim - it is already pretty-printed with sorted keys +upstream, so an unmodified copy produces readable diffs when a field +changes. Reformatting it would bury the real change in noise. + +Refreshing the spec is a deliberate act: the diff is the list of API +changes this SDK has not accounted for yet, and it belongs in the same +pull request as the code that responds to it. diff --git a/testdata/openapi.json b/testdata/openapi.json new file mode 100644 index 0000000..0eb59fe --- /dev/null +++ b/testdata/openapi.json @@ -0,0 +1,19699 @@ +{ + "components": { + "responses": {}, + "schemas": { + "EntraDirectoryResponse": { + "description": "Response schema for single Entra Directory", + "properties": { + "data": { + "$ref": "#/components/schemas/EntraDirectory" + } + }, + "title": "EntraDirectoryResponse", + "type": "object" + }, + "IruDeviceListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/IruDevice" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "IruDeviceListResponse", + "type": "object" + }, + "ActorResponse": { + "description": "Response schema for single Actor", + "properties": { + "data": { + "$ref": "#/components/schemas/Actor" + } + }, + "title": "ActorResponse", + "type": "object" + }, + "GoogleAuthProviderListResponse": { + "description": "Response schema for multiple Google Auth Providers", + "properties": { + "data": { + "description": "Google Auth Provider details", + "items": { + "$ref": "#/components/schemas/GoogleAuthProvider" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "GoogleAuthProviderListResponse", + "type": "object" + }, + "GroupResponse": { + "description": "Response schema for single Group", + "properties": { + "data": { + "$ref": "#/components/schemas/Group" + } + }, + "title": "GroupResponse", + "type": "object" + }, + "GoogleDirectoryResponse": { + "description": "Response schema for single Google Directory", + "properties": { + "data": { + "$ref": "#/components/schemas/GoogleDirectory" + } + }, + "title": "GoogleDirectoryResponse", + "type": "object" + }, + "MembershipPutRequest": { + "description": "PUT body for updating Memberships", + "properties": { + "memberships": { + "example": [ + { + "actor_id": "4ddfa557-7dfc-484f-894c-2024ec3fe9f7" + }, + { + "actor_id": "89d22f71-939d-442d-b148-897b730adfb4" + } + ], + "items": { + "properties": { + "actor_id": { + "description": "Actor ID", + "format": "uuid", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "memberships" + ], + "title": "MembershipPutRequest", + "type": "object" + }, + "IruPostureProvider": { + "description": "Iru (formerly Kandji) posture provider", + "properties": { + "account_id": { + "format": "uuid", + "type": "string" + }, + "disabled_reason": { + "nullable": true, + "type": "string" + }, + "error_message": { + "nullable": true, + "type": "string" + }, + "errored_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "type": "boolean" + }, + "is_verified": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "region": { + "enum": [ + "us", + "eu" + ], + "type": "string" + }, + "subdomain": { + "type": "string" + }, + "synced_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "type": { + "enum": [ + "iru" + ], + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "disabled_reason", + "error_message", + "errored_at", + "id", + "inserted_at", + "is_disabled", + "is_verified", + "name", + "region", + "subdomain", + "synced_at", + "type", + "updated_at" + ], + "title": "IruPostureProvider", + "type": "object" + }, + "SentinelOneDeviceResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/SentinelOneDevice" + } + }, + "title": "SentinelOneDeviceResponse", + "type": "object" + }, + "IntuneDeviceListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/IntuneDevice" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "IntuneDeviceListResponse", + "type": "object" + }, + "PolicyCreateParams": { + "description": "Policy attributes accepted when creating a Policy", + "properties": { + "conditions": { + "description": "Conditions that must be satisfied for the Policy to grant access", + "example": [ + { + "operator": "is_in", + "property": "remote_ip_location_region", + "values": [ + "US", + "CA" + ] + } + ], + "items": { + "$ref": "#/components/schemas/PolicyCondition" + }, + "type": "array" + }, + "description": { + "description": "Policy Description", + "example": "Policy to allow something", + "nullable": true, + "type": "string" + }, + "flow_log_uploads_enabled": { + "default": true, + "description": "Whether flow logs are reported for connections authorized by this Policy. Defaults to true. Always false for Internet Resource policies.", + "example": true, + "type": "boolean" + }, + "group_id": { + "description": "Group ID", + "example": "88eae9ce-9179-48c6-8430-770e38dd4775", + "format": "uuid", + "type": "string" + }, + "resource_id": { + "description": "Resource ID", + "example": "a9f60587-793c-46ae-8525-597f43ab2fb1", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "group_id", + "resource_id" + ], + "title": "PolicyCreateParams", + "type": "object" + }, + "SantaDevice": { + "description": "Santa host synced from North Pole Security Workshop", + "properties": { + "account_id": { + "format": "uuid", + "nullable": false, + "type": "string" + }, + "configured_client_mode": { + "nullable": true, + "type": "string" + }, + "first_seen_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "hostname": { + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "nullable": false, + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "last_preflight_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "last_preflight_ip": { + "format": "byte", + "nullable": true, + "type": "string" + }, + "last_seen_client_mode": { + "nullable": true, + "type": "string" + }, + "last_sync_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "machine_model": { + "nullable": true, + "type": "string" + }, + "os_build": { + "nullable": true, + "type": "string" + }, + "os_type": { + "nullable": true, + "type": "string" + }, + "os_version": { + "nullable": true, + "type": "string" + }, + "posture_provider_id": { + "format": "uuid", + "nullable": false, + "type": "string" + }, + "primary_user": { + "nullable": true, + "type": "string" + }, + "primary_user_groups": { + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, + "primary_user_locked": { + "nullable": true, + "type": "boolean" + }, + "rule_sync_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "santa_id": { + "nullable": false, + "type": "string" + }, + "santa_version": { + "nullable": true, + "type": "string" + }, + "santanetd_version": { + "nullable": true, + "type": "string" + }, + "serial_number": { + "nullable": true, + "type": "string" + }, + "sip_status": { + "nullable": true, + "type": "integer" + }, + "synced_at": { + "format": "date-time", + "nullable": false, + "type": "string" + }, + "tags": { + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, + "tags_locked": { + "nullable": true, + "type": "boolean" + }, + "tags_truncated": { + "nullable": true, + "type": "boolean" + }, + "temporary_admin_mode_ends_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "temporary_admin_mode_user": { + "nullable": true, + "type": "string" + }, + "temporary_monitor_mode_ends_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "required": [ + "account_id", + "id", + "santa_id", + "posture_provider_id", + "serial_number", + "machine_model", + "hostname", + "os_version", + "os_build", + "os_type", + "sip_status", + "primary_user", + "primary_user_locked", + "primary_user_groups", + "santa_version", + "santanetd_version", + "last_seen_client_mode", + "last_sync_at", + "rule_sync_at", + "last_preflight_at", + "last_preflight_ip", + "tags", + "tags_locked", + "tags_truncated", + "configured_client_mode", + "temporary_monitor_mode_ends_at", + "first_seen_at", + "temporary_admin_mode_ends_at", + "temporary_admin_mode_user", + "synced_at", + "inserted_at", + "updated_at" + ], + "title": "SantaDevice", + "type": "object" + }, + "IntunePostureProviderListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/IntunePostureProvider" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "IntunePostureProviderListResponse", + "type": "object" + }, + "SantaPostureProvider": { + "description": "Santa posture provider backed by North Pole Security Workshop", + "properties": { + "account_id": { + "format": "uuid", + "type": "string" + }, + "api_url": { + "format": "uri", + "type": "string" + }, + "disabled_reason": { + "nullable": true, + "type": "string" + }, + "error_message": { + "nullable": true, + "type": "string" + }, + "errored_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "type": "boolean" + }, + "is_verified": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "synced_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "type": { + "enum": [ + "santa" + ], + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "api_url", + "disabled_reason", + "error_message", + "errored_at", + "id", + "inserted_at", + "is_disabled", + "is_verified", + "name", + "synced_at", + "type", + "updated_at" + ], + "title": "SantaPostureProvider", + "type": "object" + }, + "OktaAuthProvider": { + "description": "Okta Auth Provider", + "properties": { + "account_id": { + "description": "Account ID", + "example": "5e6f7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f", + "format": "uuid", + "type": "string" + }, + "client_id": { + "description": "Client ID", + "example": "0oa1b2c3d4e5EXAMPLE", + "type": "string" + }, + "client_session_lifetime_secs": { + "description": "Client session lifetime in seconds. Null when the account default applies.", + "example": 604800, + "nullable": true, + "type": "integer" + }, + "context": { + "description": "Context", + "enum": [ + "clients_and_portal", + "clients_only", + "portal_only" + ], + "example": "clients_and_portal", + "type": "string" + }, + "id": { + "description": "Provider ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "is_default": { + "description": "Whether provider is default", + "example": false, + "type": "boolean" + }, + "is_disabled": { + "description": "Whether provider is disabled", + "example": false, + "type": "boolean" + }, + "issuer": { + "description": "Issuer", + "example": "https://example.okta.com", + "type": "string" + }, + "name": { + "description": "Provider name", + "example": "Okta", + "type": "string" + }, + "okta_domain": { + "description": "Okta domain", + "example": "example.okta.com", + "type": "string" + }, + "portal_session_lifetime_secs": { + "description": "Portal session lifetime in seconds. Null when the account default applies.", + "example": 28800, + "nullable": true, + "type": "integer" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "client_id", + "client_session_lifetime_secs", + "context", + "id", + "inserted_at", + "is_default", + "is_disabled", + "issuer", + "name", + "okta_domain", + "portal_session_lifetime_secs", + "updated_at" + ], + "title": "OktaAuthProvider", + "type": "object" + }, + "GatewayResponse": { + "description": "Response schema for single Gateway", + "properties": { + "data": { + "$ref": "#/components/schemas/Gateway" + } + }, + "title": "GatewayResponse", + "type": "object" + }, + "ResourceCreateRequest": { + "description": "POST body for creating a Resource. `site_id` is required.\n\nDevice pools (`static_device_pool`) cannot currently be created through this API - create them in the admin portal. Existing pools can be read, updated, deleted, and have their members managed here as normal.", + "properties": { + "resource": { + "properties": { + "address": { + "description": "Resource address.", + "example": "10.0.0.10", + "nullable": true, + "type": "string" + }, + "address_description": { + "description": "Resource address description", + "example": "Production Database", + "nullable": true, + "type": "string" + }, + "filters": { + "description": "Traffic filters restricting the protocols and ports the Resource exposes", + "example": [ + { + "ports": [ + "5432" + ], + "protocol": "tcp" + } + ], + "items": { + "$ref": "#/components/schemas/ResourceFilter" + }, + "type": "array" + }, + "ip_stack": { + "description": "IP stack type. Only supported for DNS resources.", + "enum": [ + "ipv4_only", + "ipv6_only", + "dual" + ], + "nullable": true, + "type": "string" + }, + "name": { + "description": "Resource name", + "example": "Prod DB", + "type": "string" + }, + "site_id": { + "description": "Site to connect the Resource to. Required. The Internet Site is reserved for the Internet Resource and cannot be used.", + "example": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "format": "uuid", + "nullable": true, + "title": "SiteID", + "type": "string" + }, + "type": { + "description": "Resource type. `internet` is accepted only in the Internet Site.", + "enum": [ + "cidr", + "ip", + "dns", + "internet" + ], + "example": "ip", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + } + }, + "required": [ + "resource" + ], + "title": "ResourceCreateRequest", + "type": "object" + }, + "ClientTokenCreateResponse": { + "description": "Response schema for a new Client Token", + "properties": { + "data": { + "$ref": "#/components/schemas/ClientTokenWithSecret" + } + }, + "title": "ClientTokenCreateResponse", + "type": "object" + }, + "DefenderPostureProvider": { + "description": "Microsoft Defender for Endpoint posture provider", + "properties": { + "account_id": { + "format": "uuid", + "type": "string" + }, + "disabled_reason": { + "nullable": true, + "type": "string" + }, + "error_message": { + "nullable": true, + "type": "string" + }, + "errored_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "type": "boolean" + }, + "is_verified": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "synced_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "tenant_id": { + "type": "string" + }, + "type": { + "enum": [ + "defender" + ], + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "disabled_reason", + "error_message", + "errored_at", + "id", + "inserted_at", + "is_disabled", + "is_verified", + "name", + "synced_at", + "tenant_id", + "type", + "updated_at" + ], + "title": "DefenderPostureProvider", + "type": "object" + }, + "GatewayCreateRequest": { + "description": "Request body for provisioning a Gateway", + "properties": { + "gateway": { + "$ref": "#/components/schemas/GatewayCreate" + } + }, + "title": "GatewayCreateRequest", + "type": "object" + }, + "ClientToken": { + "description": "Client Token metadata", + "properties": { + "actor_id": { + "description": "Actor ID", + "example": "43a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "expires_at": { + "description": "Expiration", + "example": "2025-01-15T12:34:56.789Z", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Client Token ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-15T12:34:56.789Z", + "format": "date-time", + "type": "string" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T12:34:56.789Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "actor_id", + "expires_at", + "id", + "inserted_at", + "updated_at" + ], + "title": "ClientToken", + "type": "object" + }, + "EntraAuthProvider": { + "description": "Entra Auth Provider", + "properties": { + "account_id": { + "description": "Account ID", + "example": "5e6f7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f", + "format": "uuid", + "type": "string" + }, + "client_session_lifetime_secs": { + "description": "Client session lifetime in seconds. Null when the account default applies.", + "example": 604800, + "nullable": true, + "type": "integer" + }, + "context": { + "description": "Context", + "enum": [ + "clients_and_portal", + "clients_only", + "portal_only" + ], + "example": "clients_and_portal", + "type": "string" + }, + "email_claim": { + "description": "OIDC claim to use as email", + "enum": [ + "email", + "upn", + "preferred_username" + ], + "example": "upn", + "type": "string" + }, + "id": { + "description": "Provider ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "is_default": { + "description": "Whether provider is default", + "example": false, + "type": "boolean" + }, + "is_disabled": { + "description": "Whether provider is disabled", + "example": false, + "type": "boolean" + }, + "issuer": { + "description": "Issuer", + "example": "https://login.microsoftonline.com/tenant-id/v2.0", + "type": "string" + }, + "name": { + "description": "Provider name", + "example": "Entra", + "type": "string" + }, + "portal_session_lifetime_secs": { + "description": "Portal session lifetime in seconds. Null when the account default applies.", + "example": 28800, + "nullable": true, + "type": "integer" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "client_session_lifetime_secs", + "context", + "email_claim", + "id", + "inserted_at", + "is_default", + "is_disabled", + "issuer", + "name", + "portal_session_lifetime_secs", + "updated_at" + ], + "title": "EntraAuthProvider", + "type": "object" + }, + "EntraAuthProviderResponse": { + "description": "Response schema for single Entra Auth Provider", + "properties": { + "data": { + "$ref": "#/components/schemas/EntraAuthProvider" + } + }, + "title": "EntraAuthProviderResponse", + "type": "object" + }, + "ClientTokenRequest": { + "description": "POST body for creating a Client Token", + "properties": { + "client_token": { + "$ref": "#/components/schemas/ClientTokenCreate" + } + }, + "required": [ + "client_token" + ], + "title": "ClientTokenRequest", + "type": "object" + }, + "ExternalIdentity": { + "description": "External Identity", + "properties": { + "account_id": { + "description": "Account ID", + "example": "5e6f7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f", + "format": "uuid", + "type": "string" + }, + "actor_id": { + "description": "Actor ID", + "example": "cdfa97e6-cca1-41db-8fc7-864daedb46df", + "format": "uuid", + "type": "string" + }, + "directory_id": { + "description": "Directory UUID reference", + "example": "9f8e7d6c-5b4a-3c2b-1a0e-9f8e7d6c5b4a", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "email": { + "description": "Email address, falling back to the IdP identifier when unset", + "example": "john.doe@example.com", + "type": "string" + }, + "family_name": { + "description": "Family name", + "example": "Doe", + "nullable": true, + "type": "string" + }, + "firezone_avatar_url": { + "description": "Firezone-hosted avatar URL", + "example": "https://avatars.firezone.dev/u/2551705710219359.png", + "nullable": true, + "type": "string" + }, + "given_name": { + "description": "Given name", + "example": "John", + "nullable": true, + "type": "string" + }, + "id": { + "description": "External Identity ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "idp_id": { + "description": "IDP-specific identifier for this identity", + "example": "2551705710219359", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "issuer": { + "description": "Identity issuer URL (e.g., 'https://accounts.google.com', 'https://company.okta.com')", + "example": "https://accounts.google.com", + "type": "string" + }, + "middle_name": { + "description": "Middle name", + "nullable": true, + "type": "string" + }, + "name": { + "description": "Full name", + "example": "John Doe", + "nullable": true, + "type": "string" + }, + "nickname": { + "description": "Nickname", + "nullable": true, + "type": "string" + }, + "picture": { + "description": "Profile picture URL", + "example": "https://example.com/avatar.jpg", + "nullable": true, + "type": "string" + }, + "preferred_username": { + "description": "Preferred username", + "example": "jdoe", + "nullable": true, + "type": "string" + }, + "profile": { + "description": "Profile URL", + "nullable": true, + "type": "string" + }, + "synced_at": { + "description": "Last sync timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "required": [ + "account_id", + "actor_id", + "directory_id", + "email", + "family_name", + "firezone_avatar_url", + "given_name", + "id", + "idp_id", + "inserted_at", + "issuer", + "middle_name", + "name", + "nickname", + "picture", + "preferred_username", + "profile", + "synced_at" + ], + "title": "ExternalIdentity", + "type": "object" + }, + "IruDevice": { + "description": "Device synced from Iru (formerly Kandji)", + "properties": { + "host_name": { + "nullable": true, + "type": "string" + }, + "gatekeeper_trusted_developers": { + "nullable": true, + "type": "boolean" + }, + "secure_boot_level": { + "nullable": true, + "type": "string" + }, + "os_name": { + "nullable": true, + "type": "string" + }, + "filevault_key_rotation_scheduled_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "firewall_enabled": { + "nullable": true, + "type": "boolean" + }, + "model_name": { + "nullable": true, + "type": "string" + }, + "firewall_logging_option": { + "nullable": true, + "type": "string" + }, + "shared_ipad": { + "nullable": true, + "type": "boolean" + }, + "local_hostname": { + "nullable": true, + "type": "string" + }, + "activation_lock_allowed_while_supervised": { + "nullable": true, + "type": "boolean" + }, + "updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "device_activation_lock_enabled": { + "nullable": true, + "type": "boolean" + }, + "gatekeeper_version": { + "nullable": true, + "type": "string" + }, + "bootstrap_token_escrowed": { + "nullable": true, + "type": "boolean" + }, + "iru_id": { + "nullable": false, + "type": "string" + }, + "synced_at": { + "format": "date-time", + "nullable": false, + "type": "string" + }, + "asset_tag": { + "nullable": true, + "type": "string" + }, + "blueprint_name": { + "nullable": true, + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "data_roaming": { + "nullable": true, + "type": "boolean" + }, + "is_removed": { + "nullable": true, + "type": "boolean" + }, + "ssv_enabled": { + "nullable": true, + "type": "boolean" + }, + "filevault_key_type": { + "nullable": true, + "type": "string" + }, + "device_capacity_gb": { + "format": "float", + "nullable": true, + "type": "number" + }, + "tags": { + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, + "filevault_key_escrowed": { + "nullable": true, + "type": "boolean" + }, + "apple_silicon": { + "nullable": true, + "type": "boolean" + }, + "serial_number": { + "nullable": true, + "type": "string" + }, + "cellular_technology": { + "nullable": true, + "type": "string" + }, + "blueprint_id": { + "nullable": true, + "type": "string" + }, + "user_manages_kext": { + "nullable": true, + "type": "boolean" + }, + "hotspot": { + "nullable": true, + "type": "boolean" + }, + "account_id": { + "format": "uuid", + "nullable": false, + "type": "string" + }, + "mdm_enabled": { + "nullable": true, + "type": "boolean" + }, + "user_email": { + "nullable": true, + "type": "string" + }, + "gatekeeper_opaque_version": { + "nullable": true, + "type": "string" + }, + "external_boot_level": { + "nullable": true, + "type": "string" + }, + "firewall_collected_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "software_update_requires_bootstrap_token": { + "nullable": true, + "type": "boolean" + }, + "xprotect_version": { + "nullable": true, + "type": "string" + }, + "agent_installed": { + "nullable": true, + "type": "boolean" + }, + "mdm_manages_kext": { + "nullable": true, + "type": "boolean" + }, + "display_os_version": { + "nullable": true, + "type": "string" + }, + "filevault_regeneration_needed": { + "nullable": true, + "type": "boolean" + }, + "is_missing": { + "nullable": true, + "type": "boolean" + }, + "last_enrolled_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "gatekeeper_enabled": { + "nullable": true, + "type": "boolean" + }, + "firewall_version": { + "nullable": true, + "type": "string" + }, + "first_enrolled_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "firewall_logging": { + "nullable": true, + "type": "boolean" + }, + "model_identifier": { + "nullable": true, + "type": "string" + }, + "os_build": { + "nullable": true, + "type": "string" + }, + "user_activation_lock_enabled": { + "nullable": true, + "type": "boolean" + }, + "activation_lock_collected_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "lost_mode_status": { + "nullable": true, + "type": "string" + }, + "startup_settings_collected_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "platform": { + "nullable": true, + "type": "string" + }, + "filevault_enabled": { + "nullable": true, + "type": "boolean" + }, + "user_is_archived": { + "nullable": true, + "type": "boolean" + }, + "activation_lock_supported": { + "nullable": true, + "type": "boolean" + }, + "supplemental_build_version": { + "nullable": true, + "type": "string" + }, + "sip_enabled": { + "nullable": true, + "type": "boolean" + }, + "firewall_allow_signed_applications": { + "nullable": true, + "type": "boolean" + }, + "model": { + "nullable": true, + "type": "string" + }, + "supplemental_os_version_extra": { + "nullable": true, + "type": "string" + }, + "user_id": { + "nullable": true, + "type": "string" + }, + "bootstrap_token_auth": { + "nullable": true, + "type": "boolean" + }, + "firewall_block_all_incoming": { + "nullable": true, + "type": "boolean" + }, + "last_check_in_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "firewall_stealth_mode": { + "nullable": true, + "type": "boolean" + }, + "kext_requires_bootstrap_token": { + "nullable": true, + "type": "boolean" + }, + "agent_version": { + "nullable": true, + "type": "string" + }, + "activation_lock_bypass_code_failed": { + "nullable": true, + "type": "boolean" + }, + "inventory_collected_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "any_signed_os": { + "nullable": true, + "type": "boolean" + }, + "filevault_collected_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "gatekeeper_collected_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "firewall_unloading": { + "nullable": true, + "type": "boolean" + }, + "os_version": { + "nullable": true, + "type": "string" + }, + "malware_removal_tool_version": { + "nullable": true, + "type": "string" + }, + "device_family": { + "nullable": true, + "type": "string" + }, + "user_name": { + "nullable": true, + "type": "string" + }, + "device_name": { + "nullable": true, + "type": "string" + }, + "posture_provider_id": { + "format": "uuid", + "nullable": false, + "type": "string" + } + }, + "required": [ + "account_id", + "iru_id", + "posture_provider_id", + "device_name", + "model", + "serial_number", + "platform", + "os_version", + "supplemental_build_version", + "supplemental_os_version_extra", + "last_check_in_at", + "user_id", + "user_name", + "user_email", + "user_is_archived", + "asset_tag", + "blueprint_id", + "blueprint_name", + "mdm_enabled", + "agent_installed", + "agent_version", + "is_missing", + "is_removed", + "first_enrolled_at", + "last_enrolled_at", + "lost_mode_status", + "tags", + "device_family", + "device_capacity_gb", + "host_name", + "local_hostname", + "apple_silicon", + "model_name", + "model_identifier", + "shared_ipad", + "cellular_technology", + "data_roaming", + "hotspot", + "os_build", + "os_name", + "display_os_version", + "inventory_collected_at", + "filevault_enabled", + "filevault_key_type", + "filevault_key_escrowed", + "filevault_regeneration_needed", + "filevault_key_rotation_scheduled_at", + "filevault_collected_at", + "firewall_enabled", + "firewall_block_all_incoming", + "firewall_logging", + "firewall_logging_option", + "firewall_stealth_mode", + "firewall_version", + "firewall_allow_signed_applications", + "firewall_unloading", + "firewall_collected_at", + "gatekeeper_enabled", + "gatekeeper_trusted_developers", + "gatekeeper_version", + "gatekeeper_opaque_version", + "xprotect_version", + "malware_removal_tool_version", + "gatekeeper_collected_at", + "sip_enabled", + "ssv_enabled", + "bootstrap_token_auth", + "bootstrap_token_escrowed", + "kext_requires_bootstrap_token", + "software_update_requires_bootstrap_token", + "external_boot_level", + "secure_boot_level", + "any_signed_os", + "mdm_manages_kext", + "user_manages_kext", + "startup_settings_collected_at", + "activation_lock_supported", + "activation_lock_allowed_while_supervised", + "device_activation_lock_enabled", + "user_activation_lock_enabled", + "activation_lock_bypass_code_failed", + "activation_lock_collected_at", + "synced_at", + "inserted_at", + "updated_at" + ], + "title": "IruDevice", + "type": "object" + }, + "IntuneDeviceResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/IntuneDevice" + } + }, + "title": "IntuneDeviceResponse", + "type": "object" + }, + "ClientPutRequest": { + "description": "PUT body for updating a Client", + "properties": { + "client": { + "$ref": "#/components/schemas/ClientPut" + } + }, + "required": [ + "client" + ], + "title": "ClientPutRequest", + "type": "object" + }, + "OktaDirectoryResponse": { + "description": "Response schema for single Okta Directory", + "properties": { + "data": { + "$ref": "#/components/schemas/OktaDirectory" + } + }, + "title": "OktaDirectoryResponse", + "type": "object" + }, + "GatewaySessionSubject": { + "description": "Subject of a session created by a Gateway (`context: \"gateway\"`).\n\nA Gateway authenticates with a token rather than as an actor, so no actor\nfields are recorded.\n", + "properties": { + "gateway_id": { + "description": "Identifier of the Gateway the session was established from.", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "ip": { + "description": "IP address the session came from.", + "nullable": true, + "type": "string" + }, + "ip_city": { + "description": "Geo-located city for `ip`, if known.", + "nullable": true, + "type": "string" + }, + "ip_lat": { + "description": "Geo-located latitude for `ip`, if known.", + "nullable": true, + "type": "number" + }, + "ip_lon": { + "description": "Geo-located longitude for `ip`, if known.", + "nullable": true, + "type": "number" + }, + "ip_region": { + "description": "Geo-located region for `ip`, if known.", + "nullable": true, + "type": "string" + }, + "token_id": { + "description": "Identifier of the Gateway token the session was established with.", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "user_agent": { + "description": "User agent reported by the Gateway.", + "nullable": true, + "type": "string" + } + }, + "required": [ + "gateway_id", + "token_id", + "ip", + "ip_region", + "ip_city", + "ip_lat", + "ip_lon", + "user_agent" + ], + "title": "GatewaySessionSubject", + "type": "object" + }, + "GatewayTokenResponse": { + "description": "Response schema for a new Gateway Token", + "properties": { + "data": { + "$ref": "#/components/schemas/GatewayToken" + } + }, + "title": "GatewayTokenResponse", + "type": "object" + }, + "GatewayUpdateRequest": { + "description": "Request body for updating a Gateway", + "properties": { + "gateway": { + "$ref": "#/components/schemas/GatewayUpdate" + } + }, + "required": [ + "gateway" + ], + "title": "GatewayUpdateRequest", + "type": "object" + }, + "GroupCreateRequest": { + "description": "POST body for creating a Group", + "properties": { + "group": { + "properties": { + "name": { + "description": "Group Name", + "example": "Engineering", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "group" + ], + "title": "GroupCreateRequest", + "type": "object" + }, + "Log": { + "description": "A single Log entry. The `type` field identifies the log stream the\nentry belongs to, which is also encoded in the first character of its\n`log_id` (`c` change, `5` session, `f` flow, `a` api_request).\n", + "oneOf": [ + { + "$ref": "#/components/schemas/ChangeLog" + }, + { + "$ref": "#/components/schemas/SessionLog" + }, + { + "$ref": "#/components/schemas/FlowLog" + }, + { + "$ref": "#/components/schemas/APIRequestLog" + } + ], + "title": "Log" + }, + "DeletedClientTokenResponse": { + "description": "Response schema for a deleted Client Token", + "properties": { + "data": { + "properties": { + "id": { + "description": "Client Token ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "title": "DeletedClientTokenResponse", + "type": "object" + }, + "ClientSessionSubject": { + "description": "Subject of a session created by a Client (`context: \"client\"`).\n\nA `Subject` plus the Client and token the session was established with.\n", + "properties": { + "actor_email": { + "description": "Email address of the actor, if any.", + "nullable": true, + "type": "string" + }, + "actor_id": { + "description": "Identifier of the actor that signed in.", + "format": "uuid", + "type": "string" + }, + "actor_name": { + "description": "Display name of the actor.", + "type": "string" + }, + "actor_type": { + "description": "Type of the actor.", + "enum": [ + "account_user", + "account_admin_user", + "service_account", + "api_client" + ], + "type": "string" + }, + "auth_provider_id": { + "description": "Identifier of the authentication provider that authenticated the actor.", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "device_id": { + "description": "Identifier of the Client the session was established from.", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "ip": { + "description": "IP address the session came from.", + "nullable": true, + "type": "string" + }, + "ip_city": { + "description": "Geo-located city for `ip`, if known.", + "nullable": true, + "type": "string" + }, + "ip_lat": { + "description": "Geo-located latitude for `ip`, if known.", + "nullable": true, + "type": "number" + }, + "ip_lon": { + "description": "Geo-located longitude for `ip`, if known.", + "nullable": true, + "type": "number" + }, + "ip_region": { + "description": "Geo-located region for `ip`, if known.", + "nullable": true, + "type": "string" + }, + "token_id": { + "description": "Identifier of the Client token the session was established with.", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "user_agent": { + "description": "User agent reported by the Client.", + "nullable": true, + "type": "string" + } + }, + "required": [ + "actor_id", + "actor_name", + "actor_email", + "actor_type", + "auth_provider_id", + "device_id", + "token_id", + "ip", + "ip_region", + "ip_city", + "ip_lat", + "ip_lon", + "user_agent" + ], + "title": "ClientSessionSubject", + "type": "object" + }, + "SessionLog": { + "description": "A single Session Log entry, recording one Client, Gateway, or Portal\nsession that was created, along with the auth context it was created\nwith.\n", + "properties": { + "context": { + "description": "The kind of session that was created.", + "enum": [ + "client", + "gateway", + "portal" + ], + "example": "client", + "type": "string" + }, + "log_id": { + "description": "Opaque identifier for the Session Log entry. A 24-character\nlowercase hexadecimal string starting with `5`.\n", + "example": "500060db0c2c8eb400000000", + "type": "string" + }, + "subject": { + "description": "Who established the session and from where. The shape depends on\n`context`:\n\n- `client`: a `ClientSessionSubject`, the actor plus the Client and\n token the session was established with.\n- `gateway`: a `GatewaySessionSubject`, the Gateway and its token. A\n Gateway authenticates as itself, so no actor fields are recorded.\n- `portal`: a plain `Subject`.\n", + "example": { + "actor_email": "admin@example.com", + "actor_id": "84e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "actor_name": "Admin User", + "actor_type": "account_admin_user", + "auth_provider_id": "98776234-1234-5678-9012-345678901234", + "device_id": "11e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "ip": "189.172.73.1", + "ip_city": "Mexico City", + "ip_lat": 19.4326, + "ip_lon": -99.1332, + "ip_region": "MX", + "token_id": "22e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "user_agent": "Linux/6.5.0 connlib/1.5.1" + }, + "oneOf": [ + { + "$ref": "#/components/schemas/ClientSessionSubject" + }, + { + "$ref": "#/components/schemas/GatewaySessionSubject" + }, + { + "$ref": "#/components/schemas/Subject" + } + ] + }, + "timestamp": { + "description": "RFC 3339 timestamp identifying when the session was created.", + "example": "2026-05-26T12:34:56.789Z", + "format": "date-time", + "type": "string" + }, + "type": { + "enum": [ + "session" + ], + "example": "session", + "type": "string" + } + }, + "required": [ + "context", + "log_id", + "subject", + "timestamp", + "type" + ], + "title": "SessionLog", + "type": "object" + }, + "ChangeLog": { + "description": "A single entry from the account audit log.\n\nEach entry records one create, update, or delete event against an\naccount-scoped object.\n", + "properties": { + "after": { + "additionalProperties": true, + "description": "The state of the object after the change. `null` for `delete`\nevents. Sensitive fields such as tokens, secrets, and password\nhashes are replaced with the literal string `\"[redacted]\"`.\n", + "example": { + "name": "Jane Smith" + }, + "nullable": true, + "type": "object" + }, + "before": { + "additionalProperties": true, + "description": "The state of the object before the change. `null` for `insert`\nevents. Sensitive fields such as tokens, secrets, and password\nhashes are replaced with the literal string `\"[redacted]\"`.\n", + "example": { + "name": "Jane Doe" + }, + "nullable": true, + "type": "object" + }, + "log_id": { + "description": "Opaque identifier for the audit log entry. A 24-character lowercase\nhexadecimal string starting with `c`, lexicographically sortable\nwithin an account and aligned with the order changes were committed.\n", + "example": "c00060db0c2c8eb400000000", + "type": "string" + }, + "object": { + "description": "The kind of object that was changed.", + "example": "actors", + "type": "string" + }, + "operation": { + "description": "The kind of change that was applied.", + "enum": [ + "insert", + "update", + "delete" + ], + "example": "update", + "type": "string" + }, + "subject": { + "$ref": "#/components/schemas/Subject" + }, + "timestamp": { + "description": "RFC 3339 timestamp identifying when the change was committed.", + "example": "2026-05-26T12:34:56.789Z", + "format": "date-time", + "type": "string" + }, + "type": { + "enum": [ + "change" + ], + "example": "change", + "type": "string" + } + }, + "required": [ + "after", + "before", + "log_id", + "object", + "operation", + "subject", + "timestamp", + "type" + ], + "title": "ChangeLog", + "type": "object" + }, + "FlowLogIngestRecord": { + "additionalProperties": false, + "description": "One flow-log record reported by connlib. Attribution is supplied by the\nrequest's ingest token rather than by each record.\n", + "oneOf": [ + { + "description": "An open report may include paths known so far, but omits its end time and counters.", + "not": { + "anyOf": [ + { + "required": [ + "flow_end" + ] + }, + { + "required": [ + "last_packet" + ] + }, + { + "required": [ + "rx_packets" + ] + }, + { + "required": [ + "tx_packets" + ] + }, + { + "required": [ + "rx_bytes" + ] + }, + { + "required": [ + "tx_bytes" + ] + } + ] + } + }, + { + "description": "A close report includes outer paths, its end time, and all counters.", + "required": [ + "outers", + "flow_end", + "last_packet", + "rx_packets", + "tx_packets", + "rx_bytes", + "tx_bytes" + ] + } + ], + "properties": { + "domain": { + "description": "Domain name for flows to DNS Resources; absent otherwise.", + "type": "string" + }, + "flow_end": { + "description": "Absent on the open report.", + "format": "date-time", + "type": "string" + }, + "flow_start": { + "format": "date-time", + "type": "string" + }, + "inner_dst_ip": { + "description": "Tunnel IP of the responder, on both sides' reports.", + "type": "string" + }, + "inner_dst_port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "inner_src_ip": { + "description": "Tunnel IP of the initiator, on both sides' reports.", + "type": "string" + }, + "inner_src_port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "last_packet": { + "format": "date-time", + "type": "string" + }, + "outers": { + "description": "Ordered outer transport paths accumulated over the flow's lifetime.", + "items": { + "additionalProperties": false, + "oneOf": [ + { + "required": [ + "src_ip", + "src_port" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "src_ip" + ] + }, + { + "required": [ + "src_port" + ] + } + ] + } + } + ], + "properties": { + "dst_ip": { + "type": "string" + }, + "dst_port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "path_activated_at": { + "description": "Time of the packet that revealed the path.", + "format": "date-time", + "type": "string" + }, + "src_ip": { + "description": "Absent together with src_port when the source is unknown.", + "type": "string" + }, + "src_port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "dst_ip", + "dst_port", + "path_activated_at" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "protocol": { + "enum": [ + "tcp", + "udp" + ], + "type": "string" + }, + "rx_bytes": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "rx_packets": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "tx_bytes": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "tx_packets": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "protocol", + "inner_src_ip", + "inner_src_port", + "inner_dst_ip", + "inner_dst_port", + "flow_start" + ], + "title": "FlowLogIngestRecord", + "type": "object" + }, + "Site": { + "description": "Site", + "properties": { + "id": { + "description": "Site ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "name": { + "description": "Site Name", + "example": "vpc-us-east", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "title": "Site", + "type": "object" + }, + "SiteResponse": { + "description": "Response schema for single Site", + "properties": { + "data": { + "$ref": "#/components/schemas/Site" + } + }, + "title": "SiteResponse", + "type": "object" + }, + "ClientTokenListResponse": { + "description": "Response schema for multiple Client Tokens", + "properties": { + "data": { + "description": "Client Token metadata", + "example": [ + { + "actor_id": "43a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "expires_at": "2025-01-15T12:34:56.789Z", + "id": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "inserted_at": "2025-01-15T12:34:56.789Z", + "updated_at": "2025-01-15T12:34:56.789Z" + } + ], + "items": { + "$ref": "#/components/schemas/ClientToken" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "ClientTokenListResponse", + "type": "object" + }, + "IruPostureProviderListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/IruPostureProvider" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "IruPostureProviderListResponse", + "type": "object" + }, + "GoogleDirectory": { + "description": "Google Directory", + "properties": { + "account_id": { + "description": "Account ID", + "example": "5e6f7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f", + "format": "uuid", + "type": "string" + }, + "disabled_reason": { + "description": "Reason for disabling", + "nullable": true, + "type": "string" + }, + "domain": { + "description": "Google Workspace domain", + "example": "example.com", + "type": "string" + }, + "error_message": { + "description": "Last error message", + "nullable": true, + "type": "string" + }, + "errored_at": { + "description": "Last error timestamp", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "group_sync_mode": { + "description": "Group sync mode", + "enum": [ + "all", + "filtered", + "disabled" + ], + "example": "all", + "type": "string" + }, + "id": { + "description": "Directory ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "impersonation_email": { + "description": "Impersonation email", + "example": "admin@example.com", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "description": "Whether directory is disabled", + "example": false, + "type": "boolean" + }, + "name": { + "description": "Directory name", + "example": "Google", + "type": "string" + }, + "orgunit_sync_enabled": { + "description": "Whether org unit sync is enabled", + "example": true, + "type": "boolean" + }, + "synced_at": { + "description": "Last sync timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "disabled_reason", + "domain", + "error_message", + "errored_at", + "group_sync_mode", + "id", + "impersonation_email", + "inserted_at", + "is_disabled", + "name", + "orgunit_sync_enabled", + "synced_at", + "updated_at" + ], + "title": "GoogleDirectory", + "type": "object" + }, + "PolicyResponse": { + "description": "Response schema for single Policy", + "properties": { + "data": { + "$ref": "#/components/schemas/Policy" + } + }, + "title": "PolicyResponse", + "type": "object" + }, + "DeletedClientTokensResponse": { + "description": "Response schema for deleted Client Tokens", + "properties": { + "data": { + "properties": { + "deleted_count": { + "description": "Number of tokens that were deleted", + "example": 3, + "type": "integer" + } + }, + "required": [ + "deleted_count" + ], + "type": "object" + } + }, + "title": "DeletedClientTokensResponse", + "type": "object" + }, + "GroupListResponse": { + "description": "Response schema for multiple Groups", + "properties": { + "data": { + "description": "Group details", + "items": { + "$ref": "#/components/schemas/Group" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "GroupListResponse", + "type": "object" + }, + "IntunePostureProviderResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/IntunePostureProvider" + } + }, + "title": "IntunePostureProviderResponse", + "type": "object" + }, + "OktaAuthProviderListResponse": { + "description": "Response schema for multiple Okta Auth Providers", + "properties": { + "data": { + "description": "Okta Auth Provider details", + "items": { + "$ref": "#/components/schemas/OktaAuthProvider" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "OktaAuthProviderListResponse", + "type": "object" + }, + "DeletedGatewayTokenResponse": { + "description": "Response schema for a deleted Gateway Token", + "properties": { + "data": { + "properties": { + "id": { + "description": "Gateway Token ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "title": "DeletedGatewayTokenResponse", + "type": "object" + }, + "EntraAuthProviderListResponse": { + "description": "Response schema for multiple Entra Auth Providers", + "properties": { + "data": { + "description": "Entra Auth Provider details", + "items": { + "$ref": "#/components/schemas/EntraAuthProvider" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "EntraAuthProviderListResponse", + "type": "object" + }, + "Membership": { + "description": "Membership", + "properties": { + "id": { + "description": "Actor ID", + "example": "7cb89288-1fb3-433e-a522-2d087e45988d", + "format": "uuid", + "type": "string" + }, + "name": { + "description": "Actor Name", + "example": "John Doe", + "type": "string" + }, + "type": { + "description": "Actor Type", + "enum": [ + "account_admin_user", + "account_user", + "api_client", + "service_account" + ], + "example": "account_user", + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "Membership", + "type": "object" + }, + "OIDCAuthProvider": { + "description": "OIDC Auth Provider", + "properties": { + "account_id": { + "description": "Account ID", + "example": "5e6f7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f", + "format": "uuid", + "type": "string" + }, + "client_id": { + "description": "Client ID", + "example": "my-client-id", + "type": "string" + }, + "client_session_lifetime_secs": { + "description": "Client session lifetime in seconds. Null when the account default applies.", + "example": 604800, + "nullable": true, + "type": "integer" + }, + "context": { + "description": "Context", + "enum": [ + "clients_and_portal", + "clients_only", + "portal_only" + ], + "example": "clients_and_portal", + "type": "string" + }, + "discovery_document_uri": { + "description": "Discovery document URI", + "example": "https://idp.example.com/.well-known/openid-configuration", + "type": "string" + }, + "email_verification_method": { + "description": "How sign-in verifies the email claim", + "enum": [ + "none", + "claim", + "proof" + ], + "example": "claim", + "type": "string" + }, + "id": { + "description": "Provider ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "is_default": { + "description": "Whether provider is default", + "example": false, + "type": "boolean" + }, + "is_disabled": { + "description": "Whether provider is disabled", + "example": false, + "type": "boolean" + }, + "issuer": { + "description": "Issuer", + "example": "https://idp.example.com", + "type": "string" + }, + "name": { + "description": "Provider name", + "example": "OIDC Provider", + "type": "string" + }, + "portal_session_lifetime_secs": { + "description": "Portal session lifetime in seconds. Null when the account default applies.", + "example": 28800, + "nullable": true, + "type": "integer" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "client_id", + "client_session_lifetime_secs", + "context", + "discovery_document_uri", + "email_verification_method", + "id", + "inserted_at", + "is_default", + "is_disabled", + "issuer", + "name", + "portal_session_lifetime_secs", + "updated_at" + ], + "title": "OIDCAuthProvider", + "type": "object" + }, + "DefenderDeviceListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/DefenderDevice" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "DefenderDeviceListResponse", + "type": "object" + }, + "ExternalIdentityListResponse": { + "description": "Response schema for multiple External Identities", + "properties": { + "data": { + "description": "External Identity details", + "items": { + "$ref": "#/components/schemas/ExternalIdentity" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "ExternalIdentityListResponse", + "type": "object" + }, + "ClientPut": { + "description": "Put schema for updating a single Client", + "properties": { + "name": { + "description": "Client Name", + "example": "John's Macbook Air", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ClientPut", + "type": "object" + }, + "ClientsResponse": { + "description": "Response schema for multiple Clients", + "properties": { + "data": { + "description": "Clients details", + "items": { + "$ref": "#/components/schemas/Client" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "ClientsResponse", + "type": "object" + }, + "GoogleDirectoryListResponse": { + "description": "Response schema for multiple Google Directories", + "properties": { + "data": { + "description": "Google Directory details", + "items": { + "$ref": "#/components/schemas/GoogleDirectory" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "GoogleDirectoryListResponse", + "type": "object" + }, + "GatewaysResponse": { + "description": "Response schema for multiple Gateways", + "properties": { + "data": { + "description": "Gateways details", + "items": { + "$ref": "#/components/schemas/Gateway" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "GatewaysResponse", + "type": "object" + }, + "PolicyCreateRequest": { + "description": "POST body for creating a Policy", + "properties": { + "policy": { + "$ref": "#/components/schemas/PolicyCreateParams" + } + }, + "required": [ + "policy" + ], + "title": "PolicyCreateRequest", + "type": "object" + }, + "ResourceResponse": { + "description": "Response schema for single Resource", + "properties": { + "data": { + "$ref": "#/components/schemas/Resource" + } + }, + "title": "ResourceResponse", + "type": "object" + }, + "ExternalIdentityResponse": { + "description": "Response schema for single External Identity", + "properties": { + "data": { + "$ref": "#/components/schemas/ExternalIdentity" + } + }, + "title": "ExternalIdentityResponse", + "type": "object" + }, + "SantaDeviceResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/SantaDevice" + } + }, + "title": "SantaDeviceResponse", + "type": "object" + }, + "OktaDirectory": { + "description": "Okta Directory", + "properties": { + "account_id": { + "description": "Account ID", + "example": "5e6f7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f", + "format": "uuid", + "type": "string" + }, + "client_id": { + "description": "Client ID", + "example": "0oa1b2c3d4e5EXAMPLE", + "type": "string" + }, + "disabled_reason": { + "description": "Reason for disabling", + "nullable": true, + "type": "string" + }, + "error_message": { + "description": "Last error message", + "nullable": true, + "type": "string" + }, + "errored_at": { + "description": "Last error timestamp", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "description": "Directory ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "description": "Whether directory is disabled", + "example": false, + "type": "boolean" + }, + "kid": { + "description": "Key ID", + "example": "kid-2f8a1c9e", + "type": "string" + }, + "name": { + "description": "Directory name", + "example": "Okta", + "type": "string" + }, + "okta_domain": { + "description": "Okta domain", + "example": "example.okta.com", + "type": "string" + }, + "synced_at": { + "description": "Last sync timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "client_id", + "disabled_reason", + "error_message", + "errored_at", + "id", + "inserted_at", + "is_disabled", + "kid", + "name", + "okta_domain", + "synced_at", + "updated_at" + ], + "title": "OktaDirectory", + "type": "object" + }, + "GatewayCreate": { + "description": "Create schema for a single Gateway", + "properties": { + "name": { + "description": "Gateway Name. Randomly generated when omitted.", + "example": "vpc-us-east", + "type": "string" + } + }, + "title": "GatewayCreate", + "type": "object" + }, + "OktaDirectoryListResponse": { + "description": "Response schema for multiple Okta Directories", + "properties": { + "data": { + "description": "Okta Directory details", + "items": { + "$ref": "#/components/schemas/OktaDirectory" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "OktaDirectoryListResponse", + "type": "object" + }, + "DefenderPostureProviderResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/DefenderPostureProvider" + } + }, + "title": "DefenderPostureProviderResponse", + "type": "object" + }, + "SentinelOnePostureProviderResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/SentinelOnePostureProvider" + } + }, + "title": "SentinelOnePostureProviderResponse", + "type": "object" + }, + "PaginationMetadata": { + "description": "Pagination metadata for paginated responses.", + "properties": { + "count": { + "description": "Total number of matching records", + "example": 1, + "type": "integer" + }, + "limit": { + "description": "Page size", + "example": 10, + "type": "integer" + }, + "next_page": { + "description": "Cursor to fetch the next page", + "nullable": true, + "type": "string" + }, + "prev_page": { + "description": "Cursor to fetch the previous page", + "nullable": true, + "type": "string" + } + }, + "required": [ + "count", + "limit", + "next_page", + "prev_page" + ], + "title": "PaginationMetadata", + "type": "object" + }, + "OktaAuthProviderResponse": { + "description": "Response schema for single Okta Auth Provider", + "properties": { + "data": { + "$ref": "#/components/schemas/OktaAuthProvider" + } + }, + "title": "OktaAuthProviderResponse", + "type": "object" + }, + "Subject": { + "description": "Identifies the actor and request context that initiated an action.\n\nUsed to describe the principal behind an audit log entry, an authorized\nflow, or any other event surfaced through the API. May be `null` when\nthe action originated outside the context of a Firezone session.\n", + "nullable": true, + "properties": { + "actor_email": { + "description": "Email address of the actor, if any.", + "example": "admin@example.com", + "nullable": true, + "type": "string" + }, + "actor_id": { + "description": "Identifier of the actor that initiated the action.", + "example": "84e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "actor_name": { + "description": "Display name of the actor.", + "example": "Admin User", + "type": "string" + }, + "actor_type": { + "description": "Type of the actor.", + "enum": [ + "account_user", + "account_admin_user", + "service_account", + "api_client" + ], + "example": "account_admin_user", + "type": "string" + }, + "auth_provider_id": { + "description": "Identifier of the authentication provider that authenticated the actor.", + "example": "98776234-1234-5678-9012-345678901234", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "ip": { + "description": "IP address the action originated from.", + "example": "1.2.3.4", + "nullable": true, + "type": "string" + }, + "ip_city": { + "description": "Geo-located city for `ip`, if known.", + "example": "San Francisco", + "nullable": true, + "type": "string" + }, + "ip_lat": { + "description": "Geo-located latitude for `ip`, if known.", + "example": 37.7749, + "nullable": true, + "type": "number" + }, + "ip_lon": { + "description": "Geo-located longitude for `ip`, if known.", + "example": -122.4194, + "nullable": true, + "type": "number" + }, + "ip_region": { + "description": "Geo-located region for `ip`, if known.", + "example": "California", + "nullable": true, + "type": "string" + }, + "user_agent": { + "description": "User agent of the client that initiated the action.", + "example": "Mozilla/5.0", + "nullable": true, + "type": "string" + } + }, + "title": "Subject", + "type": "object" + }, + "IruPostureProviderResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/IruPostureProvider" + } + }, + "title": "IruPostureProviderResponse", + "type": "object" + }, + "GatewayProvisionResponse": { + "description": "Response schema for a newly provisioned Gateway. Includes the one-time token secret - it is not shown again.\n", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/Gateway" + }, + { + "properties": { + "token": { + "description": "One-time Gateway token secret", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + } + ] + } + }, + "title": "GatewayProvisionResponse", + "type": "object" + }, + "X509AuthProvider": { + "description": "X.509 Auth Provider", + "properties": { + "account_id": { + "description": "Account ID", + "example": "5e6f7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f", + "format": "uuid", + "type": "string" + }, + "context": { + "description": "Context", + "enum": [ + "clients_only" + ], + "example": "clients_only", + "type": "string" + }, + "id": { + "description": "Provider ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "description": "Whether provider is disabled", + "example": true, + "type": "boolean" + }, + "name": { + "description": "Provider name", + "example": "X.509", + "type": "string" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "context", + "id", + "inserted_at", + "is_disabled", + "name", + "updated_at" + ], + "title": "X509AuthProvider", + "type": "object" + }, + "ActorsResponse": { + "description": "Response schema for multiple Actors", + "properties": { + "data": { + "description": "Actors details", + "items": { + "$ref": "#/components/schemas/Actor" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "ActorsResponse", + "type": "object" + }, + "GoogleAuthProviderResponse": { + "description": "Response schema for single Google Auth Provider", + "properties": { + "data": { + "$ref": "#/components/schemas/GoogleAuthProvider" + } + }, + "title": "GoogleAuthProviderResponse", + "type": "object" + }, + "ActorUpdateRequest": { + "description": "PATCH/PUT body for updating an Actor. All fields are optional; omitted fields keep their current value.", + "properties": { + "actor": { + "properties": { + "allow_email_otp_sign_in": { + "default": false, + "description": "Allow Email OTP Sign In", + "example": false, + "type": "boolean" + }, + "email": { + "description": "Actor Email. Optional for service accounts.", + "example": "joe.user@example.com", + "nullable": true, + "type": "string" + }, + "is_disabled": { + "default": false, + "description": "Whether the Actor is disabled. Setting this to `true` immediately revokes the Actor's active Client tokens and portal sessions. An Actor cannot disable itself.", + "example": false, + "type": "boolean" + }, + "name": { + "description": "Actor Name", + "example": "Joe User", + "type": "string" + }, + "type": { + "description": "Actor Type", + "enum": [ + "account_user", + "account_admin_user", + "service_account" + ], + "example": "account_admin_user", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "actor" + ], + "title": "ActorUpdateRequest", + "type": "object" + }, + "MembershipResponse": { + "description": "Response schema for Membership Updates", + "properties": { + "data": { + "description": "Memberships", + "properties": { + "actor_ids": { + "description": "Actor IDs", + "example": [ + "4ddfa557-7dfc-484f-894c-2024ec3fe9f7", + "89d22f71-939d-442d-b148-897b730adfb4" + ], + "items": { + "description": "Actor ID", + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + }, + "title": "MembershipResponse", + "type": "object" + }, + "MembershipListResponse": { + "description": "Response schema for Memberships", + "properties": { + "data": { + "description": "Membership details", + "items": { + "$ref": "#/components/schemas/Membership" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "MembershipListResponse", + "type": "object" + }, + "Actor": { + "description": "Actor", + "properties": { + "allow_email_otp_sign_in": { + "default": false, + "description": "Allow Email OTP Sign In", + "example": false, + "type": "boolean" + }, + "created_by_directory_id": { + "description": "Directory ID that created this actor", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "email": { + "description": "Actor Email", + "example": "john.doe@example.com", + "nullable": true, + "type": "string" + }, + "id": { + "description": "Actor ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "When the actor was created", + "example": "2024-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "default": false, + "description": "Whether the actor is disabled", + "example": false, + "type": "boolean" + }, + "last_seen_at": { + "description": "Last time the actor was seen", + "example": "2024-01-15T10:30:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "name": { + "description": "Actor Name", + "example": "John Doe", + "type": "string" + }, + "type": { + "description": "Actor Type", + "enum": [ + "account_admin_user", + "account_user", + "api_client", + "service_account" + ], + "example": "account_admin_user", + "type": "string" + }, + "updated_at": { + "description": "When the actor was last updated", + "example": "2024-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "allow_email_otp_sign_in", + "created_by_directory_id", + "email", + "id", + "inserted_at", + "is_disabled", + "last_seen_at", + "name", + "type", + "updated_at" + ], + "title": "Actor", + "type": "object" + }, + "DeletedGatewayTokensResponse": { + "description": "Response schema for deleted Gateway Tokens", + "properties": { + "data": { + "properties": { + "deleted_count": { + "description": "Number of tokens that were deleted", + "example": 5, + "type": "integer" + } + }, + "required": [ + "deleted_count" + ], + "type": "object" + } + }, + "title": "DeletedGatewayTokensResponse", + "type": "object" + }, + "PolicyUpdateParams": { + "description": "Policy attributes accepted when updating a Policy. All fields are optional; omitted fields keep their current value.", + "properties": { + "conditions": { + "description": "Conditions that must be satisfied for the Policy to grant access", + "example": [ + { + "operator": "is_in_cidr", + "property": "remote_ip", + "values": [ + "10.0.0.0/8" + ] + } + ], + "items": { + "$ref": "#/components/schemas/PolicyCondition" + }, + "type": "array" + }, + "description": { + "description": "Policy Description", + "example": "Updated description", + "nullable": true, + "type": "string" + }, + "flow_log_uploads_enabled": { + "description": "Whether flow logs are reported for connections authorized by this Policy. Always false for Internet Resource policies.", + "type": "boolean" + }, + "group_id": { + "description": "Group ID", + "format": "uuid", + "type": "string" + }, + "is_disabled": { + "default": false, + "description": "Whether the Policy is disabled. A disabled Policy grants no access but is otherwise retained.", + "example": false, + "type": "boolean" + }, + "resource_id": { + "description": "Resource ID", + "format": "uuid", + "type": "string" + } + }, + "title": "PolicyUpdateParams", + "type": "object" + }, + "ClientTokenShowResponse": { + "description": "Response schema for Client Token metadata", + "properties": { + "data": { + "$ref": "#/components/schemas/ClientToken" + } + }, + "title": "ClientTokenShowResponse", + "type": "object" + }, + "DefenderDevice": { + "description": "Device synced from Microsoft Defender for Endpoint", + "properties": { + "last_seen_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "machine_tags": { + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, + "managed_by": { + "nullable": true, + "type": "string" + }, + "vm_resource_id": { + "nullable": true, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "os_platform": { + "nullable": true, + "type": "string" + }, + "synced_at": { + "format": "date-time", + "nullable": false, + "type": "string" + }, + "last_ip_address": { + "nullable": true, + "type": "string" + }, + "device_value": { + "nullable": true, + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "os_architecture": { + "nullable": true, + "type": "string" + }, + "rbac_group_name": { + "nullable": true, + "type": "string" + }, + "exposure_level": { + "nullable": true, + "type": "string" + }, + "defender_id": { + "nullable": false, + "type": "string" + }, + "entra_joined": { + "nullable": true, + "type": "boolean" + }, + "exclusion_reason": { + "nullable": true, + "type": "string" + }, + "vm_id": { + "nullable": true, + "type": "string" + }, + "vm_cloud_provider": { + "nullable": true, + "type": "string" + }, + "os_processor": { + "nullable": true, + "type": "string" + }, + "account_id": { + "format": "uuid", + "nullable": false, + "type": "string" + }, + "merged_into_machine_id": { + "nullable": true, + "type": "string" + }, + "entra_device_id": { + "nullable": true, + "type": "string" + }, + "os_build": { + "nullable": true, + "type": "integer" + }, + "managed_by_status": { + "nullable": true, + "type": "string" + }, + "is_excluded": { + "nullable": true, + "type": "boolean" + }, + "vm_subscription_id": { + "nullable": true, + "type": "string" + }, + "last_external_ip_address": { + "nullable": true, + "type": "string" + }, + "health_status": { + "nullable": true, + "type": "string" + }, + "risk_score": { + "nullable": true, + "type": "string" + }, + "is_potential_duplication": { + "nullable": true, + "type": "boolean" + }, + "agent_version": { + "nullable": true, + "type": "string" + }, + "rbac_group_id": { + "nullable": true, + "type": "string" + }, + "version": { + "nullable": true, + "type": "string" + }, + "ip_addresses": { + "items": { + "type": "object" + }, + "nullable": true, + "type": "array" + }, + "onboarding_status": { + "nullable": true, + "type": "string" + }, + "computer_dns_name": { + "nullable": true, + "type": "string" + }, + "first_seen_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "posture_provider_id": { + "format": "uuid", + "nullable": false, + "type": "string" + } + }, + "required": [ + "account_id", + "defender_id", + "posture_provider_id", + "computer_dns_name", + "entra_device_id", + "entra_joined", + "machine_tags", + "os_platform", + "version", + "os_build", + "os_processor", + "os_architecture", + "last_ip_address", + "last_external_ip_address", + "agent_version", + "health_status", + "onboarding_status", + "managed_by", + "managed_by_status", + "risk_score", + "exposure_level", + "device_value", + "rbac_group_id", + "rbac_group_name", + "is_potential_duplication", + "merged_into_machine_id", + "is_excluded", + "exclusion_reason", + "vm_id", + "vm_cloud_provider", + "vm_resource_id", + "vm_subscription_id", + "ip_addresses", + "first_seen_at", + "last_seen_at", + "synced_at", + "inserted_at", + "updated_at" + ], + "title": "DefenderDevice", + "type": "object" + }, + "ClientTokenWithSecret": { + "description": "Client Token as returned when it is created, including the encoded secret. The secret is shown once and cannot be retrieved later.", + "properties": { + "actor_id": { + "description": "Actor ID", + "example": "43a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "expires_at": { + "description": "Expiration", + "example": "2025-01-15T12:34:56.789Z", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Client Token ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-15T12:34:56.789Z", + "format": "date-time", + "type": "string" + }, + "token": { + "description": "Encoded token secret", + "example": "secret-token-here", + "type": "string" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T12:34:56.789Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "actor_id", + "expires_at", + "id", + "inserted_at", + "token", + "updated_at" + ], + "title": "ClientTokenWithSecret", + "type": "object" + }, + "SentinelOneDevice": { + "description": "Endpoint agent synced from SentinelOne", + "properties": { + "last_logged_in_user_name": { + "nullable": true, + "type": "string" + }, + "sentinelone_id": { + "nullable": true, + "type": "string" + }, + "user_actions_needed": { + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, + "operational_state": { + "nullable": true, + "type": "string" + }, + "os_name": { + "nullable": true, + "type": "string" + }, + "os_arch": { + "nullable": true, + "type": "string" + }, + "remote_profiling_state": { + "nullable": true, + "type": "string" + }, + "has_containerized_workload": { + "nullable": true, + "type": "boolean" + }, + "firewall_enabled": { + "nullable": true, + "type": "boolean" + }, + "proxy_console": { + "nullable": true, + "type": "boolean" + }, + "model_name": { + "nullable": true, + "type": "string" + }, + "infected": { + "nullable": true, + "type": "boolean" + }, + "proxy_deep_visibility": { + "nullable": true, + "type": "boolean" + }, + "console_migration_status": { + "nullable": true, + "type": "string" + }, + "is_hyper_automate": { + "nullable": true, + "type": "boolean" + }, + "network_quarantine_enabled": { + "nullable": true, + "type": "boolean" + }, + "scan_finished_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "is_active": { + "nullable": true, + "type": "boolean" + }, + "external_id": { + "nullable": true, + "type": "string" + }, + "last_successful_scan_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "os_username": { + "nullable": true, + "type": "string" + }, + "active_protection": { + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, + "threat_reboot_required": { + "nullable": true, + "type": "boolean" + }, + "synced_at": { + "format": "date-time", + "nullable": false, + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "location_enabled": { + "nullable": true, + "type": "boolean" + }, + "group_id": { + "nullable": true, + "type": "string" + }, + "site_id": { + "nullable": true, + "type": "string" + }, + "storage_name": { + "nullable": true, + "type": "string" + }, + "source_created_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "machine_sid": { + "nullable": true, + "type": "string" + }, + "remote_profiling_state_expires_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "group_name": { + "nullable": true, + "type": "string" + }, + "allow_remote_shell": { + "nullable": true, + "type": "boolean" + }, + "tags": { + "items": { + "type": "object" + }, + "nullable": true, + "type": "array" + }, + "protected_pods_count": { + "nullable": true, + "type": "integer" + }, + "os_type": { + "nullable": true, + "type": "string" + }, + "proxy_pac_file_usage": { + "nullable": true, + "type": "boolean" + }, + "serial_number": { + "nullable": true, + "type": "string" + }, + "ad_computer_member_of": { + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, + "locations": { + "items": { + "type": "object" + }, + "nullable": true, + "type": "array" + }, + "installer_type": { + "nullable": true, + "type": "string" + }, + "last_active_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "is_ad_connector": { + "nullable": true, + "type": "boolean" + }, + "ad_user_principal_name": { + "nullable": true, + "type": "string" + }, + "core_count": { + "nullable": true, + "type": "integer" + }, + "detection_state": { + "nullable": true, + "type": "string" + }, + "active_threats": { + "nullable": true, + "type": "integer" + }, + "is_decommissioned": { + "nullable": true, + "type": "boolean" + }, + "account_id": { + "format": "uuid", + "nullable": false, + "type": "string" + }, + "proxy_console_address": { + "nullable": true, + "type": "string" + }, + "cpu_count": { + "nullable": true, + "type": "integer" + }, + "group_ip": { + "nullable": true, + "type": "string" + }, + "protected_tasks_count": { + "nullable": true, + "type": "integer" + }, + "proxy_deep_visibility_address": { + "nullable": true, + "type": "string" + }, + "storage_type": { + "nullable": true, + "type": "string" + }, + "account_name": { + "nullable": true, + "type": "string" + }, + "ranger_status": { + "nullable": true, + "type": "string" + }, + "scan_status": { + "nullable": true, + "type": "string" + }, + "total_memory": { + "nullable": true, + "type": "integer" + }, + "policy_updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "encrypted_applications": { + "nullable": true, + "type": "boolean" + }, + "ad_last_user_distinguished_name": { + "nullable": true, + "type": "string" + }, + "missing_permissions": { + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, + "is_uninstalled": { + "nullable": true, + "type": "boolean" + }, + "computer_name": { + "nullable": true, + "type": "string" + }, + "scan_aborted_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "in_remote_shell_session": { + "nullable": true, + "type": "boolean" + }, + "first_full_mode_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "ad_last_user_member_of": { + "items": { + "type": "string" + }, + "nullable": true, + "type": "array" + }, + "source_updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "ad_mail": { + "nullable": true, + "type": "string" + }, + "show_alert_icon": { + "nullable": true, + "type": "boolean" + }, + "external_ip": { + "nullable": true, + "type": "string" + }, + "is_pending_uninstall": { + "nullable": true, + "type": "boolean" + }, + "sentinelone_account_id": { + "nullable": true, + "type": "string" + }, + "last_ip_to_management": { + "nullable": true, + "type": "string" + }, + "proxy_method": { + "nullable": true, + "type": "string" + }, + "group_updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "network_status": { + "nullable": true, + "type": "string" + }, + "os_revision": { + "nullable": true, + "type": "string" + }, + "agent_version": { + "nullable": true, + "type": "string" + }, + "registered_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "is_up_to_date": { + "nullable": true, + "type": "boolean" + }, + "site_name": { + "nullable": true, + "type": "string" + }, + "apps_vulnerability_status": { + "nullable": true, + "type": "string" + }, + "domain": { + "nullable": true, + "type": "string" + }, + "ad_computer_distinguished_name": { + "nullable": true, + "type": "string" + }, + "os_start_time": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "mitigation_mode_suspicious": { + "nullable": true, + "type": "string" + }, + "machine_type": { + "nullable": true, + "type": "string" + }, + "uuid": { + "nullable": false, + "type": "string" + }, + "ranger_version": { + "nullable": true, + "type": "string" + }, + "mitigation_mode": { + "nullable": true, + "type": "string" + }, + "operational_state_expires_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "network_interfaces": { + "items": { + "type": "object" + }, + "nullable": true, + "type": "array" + }, + "full_disk_scan_updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "cloud_providers": { + "additionalProperties": true, + "nullable": true, + "type": "object" + }, + "posture_provider_id": { + "format": "uuid", + "nullable": false, + "type": "string" + }, + "scan_started_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "protected_containers_count": { + "nullable": true, + "type": "integer" + }, + "cpu_id": { + "nullable": true, + "type": "string" + }, + "location_type": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "account_id", + "uuid", + "posture_provider_id", + "source_created_at", + "source_updated_at", + "group_updated_at", + "policy_updated_at", + "sentinelone_account_id", + "account_name", + "site_id", + "site_name", + "group_id", + "group_name", + "sentinelone_id", + "agent_version", + "network_interfaces", + "domain", + "computer_name", + "os_name", + "os_revision", + "os_arch", + "os_username", + "os_start_time", + "os_type", + "total_memory", + "model_name", + "machine_type", + "cpu_id", + "cpu_count", + "core_count", + "external_ip", + "group_ip", + "active_threats", + "infected", + "threat_reboot_required", + "last_active_at", + "is_active", + "is_up_to_date", + "network_status", + "registered_at", + "is_pending_uninstall", + "is_uninstalled", + "is_decommissioned", + "encrypted_applications", + "last_logged_in_user_name", + "ad_last_user_distinguished_name", + "ad_last_user_member_of", + "ad_computer_distinguished_name", + "ad_computer_member_of", + "ad_user_principal_name", + "ad_mail", + "scan_status", + "scan_started_at", + "scan_finished_at", + "scan_aborted_at", + "full_disk_scan_updated_at", + "mitigation_mode", + "mitigation_mode_suspicious", + "user_actions_needed", + "missing_permissions", + "console_migration_status", + "apps_vulnerability_status", + "in_remote_shell_session", + "allow_remote_shell", + "locations", + "location_type", + "external_id", + "serial_number", + "machine_sid", + "installer_type", + "ranger_version", + "ranger_status", + "last_ip_to_management", + "operational_state", + "operational_state_expires_at", + "remote_profiling_state", + "remote_profiling_state_expires_at", + "network_quarantine_enabled", + "firewall_enabled", + "location_enabled", + "cloud_providers", + "storage_type", + "storage_name", + "detection_state", + "first_full_mode_at", + "tags", + "show_alert_icon", + "last_successful_scan_at", + "proxy_console", + "proxy_deep_visibility", + "proxy_pac_file_usage", + "proxy_method", + "proxy_console_address", + "proxy_deep_visibility_address", + "protected_pods_count", + "protected_containers_count", + "protected_tasks_count", + "has_containerized_workload", + "is_ad_connector", + "is_hyper_automate", + "active_protection", + "synced_at", + "inserted_at", + "updated_at" + ], + "title": "SentinelOneDevice", + "type": "object" + }, + "Resource": { + "description": "Resource", + "properties": { + "address": { + "description": "Resource address. Null for `static_device_pool` Resources.", + "example": "10.0.0.10", + "nullable": true, + "type": "string" + }, + "address_description": { + "description": "Resource address description", + "example": "Production Database", + "nullable": true, + "type": "string" + }, + "filters": { + "description": "Traffic filters restricting the protocols and ports the Resource exposes", + "example": [ + { + "ports": [ + "5432" + ], + "protocol": "tcp" + } + ], + "items": { + "$ref": "#/components/schemas/ResourceFilter" + }, + "type": "array" + }, + "id": { + "description": "Resource ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "ip_stack": { + "description": "IP stack type. Only supported for DNS resources.", + "enum": [ + "ipv4_only", + "ipv6_only", + "dual" + ], + "type": "string" + }, + "name": { + "description": "Resource name", + "example": "Prod DB", + "type": "string" + }, + "site_id": { + "description": "Site to connect the Resource to. Required for all types except `static_device_pool`.", + "example": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "format": "uuid", + "title": "SiteID", + "type": "string" + }, + "type": { + "description": "Resource type. For `static_device_pool` and `dynamic_device_pool`, `address` is not applicable. Only `cidr`, `ip`, and `dns` Resources can be created through the API.", + "enum": [ + "cidr", + "ip", + "dns", + "internet", + "static_device_pool", + "dynamic_device_pool" + ], + "example": "ip", + "type": "string" + } + }, + "required": [ + "address", + "address_description", + "filters", + "id", + "name", + "type" + ], + "title": "Resource", + "type": "object" + }, + "X509AuthProviderResponse": { + "description": "Response schema for a single X.509 Auth Provider", + "properties": { + "data": { + "$ref": "#/components/schemas/X509AuthProvider" + } + }, + "title": "X509AuthProviderResponse", + "type": "object" + }, + "ClientTokenCreate": { + "description": "Client Token attributes", + "properties": { + "expires_at": { + "description": "Expiration", + "example": "2025-01-15T12:34:56.789Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "expires_at" + ], + "title": "ClientTokenCreate", + "type": "object" + }, + "DefenderPostureProviderListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/DefenderPostureProvider" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "DefenderPostureProviderListResponse", + "type": "object" + }, + "EntraDirectory": { + "description": "Entra Directory", + "properties": { + "account_id": { + "description": "Account ID", + "example": "5e6f7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f", + "format": "uuid", + "type": "string" + }, + "disabled_reason": { + "description": "Reason for disabling", + "nullable": true, + "type": "string" + }, + "email_field": { + "description": "Graph API user field to use as email", + "enum": [ + "mail", + "userPrincipalName" + ], + "example": "userPrincipalName", + "type": "string" + }, + "error_message": { + "description": "Last error message", + "nullable": true, + "type": "string" + }, + "errored_at": { + "description": "Last error timestamp", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "description": "Directory ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "description": "Whether directory is disabled", + "example": false, + "type": "boolean" + }, + "name": { + "description": "Directory name", + "example": "Entra", + "type": "string" + }, + "sync_all_groups": { + "description": "Sync all groups", + "example": false, + "type": "boolean" + }, + "synced_at": { + "description": "Last sync timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "tenant_id": { + "description": "Microsoft Entra tenant ID", + "example": "12345678-1234-1234-1234-123456789012", + "type": "string" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "disabled_reason", + "email_field", + "error_message", + "errored_at", + "id", + "inserted_at", + "is_disabled", + "name", + "sync_all_groups", + "synced_at", + "tenant_id", + "updated_at" + ], + "title": "EntraDirectory", + "type": "object" + }, + "LogsResponse": { + "description": "Response schema for a page of Log entries.\n\nEntries are returned most recent first. Each page contains at most 100\nentries (50 by default); use the `metadata.next_page` cursor to fetch\nthe following page.\n", + "properties": { + "data": { + "description": "Log entries for the requested window.", + "example": [ + { + "after": { + "name": "Jane Smith" + }, + "before": { + "name": "Jane Doe" + }, + "log_id": "c00060db0c2c8eb400000000", + "object": "actors", + "operation": "update", + "subject": null, + "timestamp": "2026-05-26T12:34:56.789Z", + "type": "change" + } + ], + "items": { + "$ref": "#/components/schemas/Log" + }, + "type": "array" + }, + "metadata": { + "description": "Pagination metadata", + "example": { + "count": 1, + "limit": 50, + "next_page": null, + "prev_page": null + }, + "type": "object" + } + }, + "title": "LogsResponse", + "type": "object" + }, + "SentinelOnePostureProviderListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/SentinelOnePostureProvider" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "SentinelOnePostureProviderListResponse", + "type": "object" + }, + "PoolMemberResponse": { + "description": "Response schema for Pool Member updates", + "properties": { + "data": { + "description": "Pool members", + "properties": { + "device_ids": { + "description": "Client IDs currently in the pool", + "example": [ + "4ddfa557-7dfc-484f-894c-2024ec3fe9f7", + "89d22f71-939d-442d-b148-897b730adfb4" + ], + "items": { + "description": "Client ID", + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + }, + "title": "PoolMemberResponse", + "type": "object" + }, + "Client": { + "description": "Client", + "properties": { + "actor_id": { + "description": "Actor ID", + "example": "6ecc106b-75c1-48a5-846c-14782180c1ff", + "format": "uuid", + "type": "string" + }, + "created_at": { + "description": "Client creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "device_serial": { + "description": "Device manufacturer serial number (unavailable for mobile devices)", + "example": "GCCFX0DBQ6L5", + "nullable": true, + "type": "string" + }, + "device_uuid": { + "description": "Device manufacturer UUID (unavailable for mobile devices)", + "example": "7A461FF9-0BE2-64A9-A418-539D9A21827B", + "nullable": true, + "type": "string" + }, + "firebase_installation_id": { + "description": "Firebase installation ID (Android only)", + "nullable": true, + "type": "string" + }, + "firezone_id": { + "description": "Identifier the Client reports for itself. Null for a Client whose identity comes from an MDM-issued certificate, since a self-reported value could otherwise be used to claim another Client's record.", + "example": "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + "nullable": true, + "type": "string" + }, + "hostname": { + "description": "Client hostname (FQDN used for dynamic device pool DNS resolution)", + "example": "johns-macbook.example.com", + "nullable": true, + "type": "string" + }, + "id": { + "description": "Client ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "identifier_for_vendor": { + "description": "App installation ID (iOS only)", + "nullable": true, + "type": "string" + }, + "ipv4": { + "description": "Tunnel IPv4 Address of Client", + "example": "100.64.0.1", + "type": "string" + }, + "ipv6": { + "description": "Tunnel IPv6 Address of Client", + "example": "fd00:2021:1111::1", + "type": "string" + }, + "last_attested_at": { + "description": "When the device last proved possession of an MDM-provisioned client certificate", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "nullable": true, + "readOnly": true, + "type": "string" + }, + "last_attested_cert_fingerprint": { + "description": "SHA-256 fingerprint of the client certificate used for device verification", + "example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "nullable": true, + "readOnly": true, + "type": "string" + }, + "last_attested_cert_serial": { + "description": "Serial number of the client certificate used for device verification", + "example": "4A:2F:00:8C:11:03:9E:5B", + "nullable": true, + "readOnly": true, + "type": "string" + }, + "last_attested_device_serial": { + "description": "Device serial number attested by an MDM-provisioned client certificate", + "example": "GCCFX0DBQ6L5", + "nullable": true, + "readOnly": true, + "type": "string" + }, + "last_attested_device_uuid": { + "description": "Device UUID attested by an MDM-provisioned client certificate", + "example": "7A461FF9-0BE2-64A9-A418-539D9A21827B", + "nullable": true, + "readOnly": true, + "type": "string" + }, + "last_attested_mdm_device_id": { + "description": "MDM device ID attested by an MDM-provisioned client certificate", + "example": "5f2e7b7a-9d54-4bd2-9d4f-8f6c2a01f9d3", + "nullable": true, + "readOnly": true, + "type": "string" + }, + "last_seen_at": { + "description": "Timestamp of the latest connection", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "last_seen_remote_ip": { + "description": "Remote IP from the latest session", + "example": "203.0.113.10", + "nullable": true, + "type": "string" + }, + "last_seen_remote_ip_location_city": { + "description": "Remote IP city from the latest session", + "example": "New York", + "nullable": true, + "type": "string" + }, + "last_seen_remote_ip_location_lat": { + "description": "Remote IP latitude from the latest session", + "example": 40.7128, + "nullable": true, + "type": "number" + }, + "last_seen_remote_ip_location_lon": { + "description": "Remote IP longitude from the latest session", + "example": -74.006, + "nullable": true, + "type": "number" + }, + "last_seen_remote_ip_location_region": { + "description": "Remote IP region from the latest session", + "example": "US", + "nullable": true, + "type": "string" + }, + "last_seen_user_agent": { + "description": "User agent from the latest session", + "example": "macOS/14.0 apple-client/1.5.0", + "nullable": true, + "type": "string" + }, + "last_seen_version": { + "description": "Client version from the latest session", + "example": "1.5.0", + "nullable": true, + "type": "string" + }, + "name": { + "description": "Client Name", + "example": "John's Macbook Air", + "type": "string" + }, + "online": { + "description": "Online status of Client", + "example": true, + "type": "boolean" + }, + "public_key": { + "description": "WireGuard public key from the latest session", + "example": "WdKAyoA45xJllRUYnFhI5+Y4EjSTs50MzYYHfrIhVAc=", + "nullable": true, + "type": "string" + }, + "updated_at": { + "description": "Client update timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "verified_at": { + "description": "Client verification timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "required": [ + "actor_id", + "created_at", + "device_serial", + "device_uuid", + "firebase_installation_id", + "firezone_id", + "hostname", + "id", + "identifier_for_vendor", + "ipv4", + "ipv6", + "last_attested_at", + "last_attested_cert_fingerprint", + "last_attested_cert_serial", + "last_attested_device_serial", + "last_attested_device_uuid", + "last_attested_mdm_device_id", + "last_seen_at", + "last_seen_remote_ip", + "last_seen_remote_ip_location_city", + "last_seen_remote_ip_location_lat", + "last_seen_remote_ip_location_lon", + "last_seen_remote_ip_location_region", + "last_seen_user_agent", + "last_seen_version", + "name", + "online", + "public_key", + "updated_at", + "verified_at" + ], + "title": "Client", + "type": "object" + }, + "SantaDeviceListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/SantaDevice" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "SantaDeviceListResponse", + "type": "object" + }, + "DefenderDeviceResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/DefenderDevice" + } + }, + "title": "DefenderDeviceResponse", + "type": "object" + }, + "EmailOTPAuthProviderResponse": { + "description": "Response schema for single Email OTP Auth Provider", + "properties": { + "data": { + "$ref": "#/components/schemas/EmailOTPAuthProvider" + } + }, + "title": "EmailOTPAuthProviderResponse", + "type": "object" + }, + "EntraDirectoryListResponse": { + "description": "Response schema for multiple Entra Directories", + "properties": { + "data": { + "description": "Entra Directory details", + "items": { + "$ref": "#/components/schemas/EntraDirectory" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "EntraDirectoryListResponse", + "type": "object" + }, + "IntunePostureProvider": { + "description": "Microsoft Intune posture provider", + "properties": { + "account_id": { + "format": "uuid", + "type": "string" + }, + "disabled_reason": { + "nullable": true, + "type": "string" + }, + "error_message": { + "nullable": true, + "type": "string" + }, + "errored_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "type": "boolean" + }, + "is_verified": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "synced_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "tenant_id": { + "type": "string" + }, + "type": { + "enum": [ + "intune" + ], + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "disabled_reason", + "error_message", + "errored_at", + "id", + "inserted_at", + "is_disabled", + "is_verified", + "name", + "synced_at", + "tenant_id", + "type", + "updated_at" + ], + "title": "IntunePostureProvider", + "type": "object" + }, + "PolicyUpdateRequest": { + "description": "PUT/PATCH body for updating a Policy", + "properties": { + "policy": { + "$ref": "#/components/schemas/PolicyUpdateParams" + } + }, + "required": [ + "policy" + ], + "title": "PolicyUpdateRequest", + "type": "object" + }, + "ValidationErrors": { + "additionalProperties": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "items": { + "$ref": "#/components/schemas/ValidationErrors" + }, + "type": "array" + }, + { + "$ref": "#/components/schemas/ValidationErrors" + } + ] + }, + "description": "Map of field name to its errors: a list of messages, or the errors of a nested object or list of objects under the same shape.", + "example": { + "name": [ + "can't be blank" + ] + }, + "title": "ValidationErrors", + "type": "object" + }, + "OIDCAuthProviderListResponse": { + "description": "Response schema for multiple OIDC Auth Providers", + "properties": { + "data": { + "description": "OIDC Auth Provider details", + "items": { + "$ref": "#/components/schemas/OIDCAuthProvider" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "OIDCAuthProviderListResponse", + "type": "object" + }, + "Gateway": { + "description": "Gateway", + "properties": { + "gateway_token_id": { + "description": "ID of the token this Gateway last connected with. Null until the Gateway connects for the first time.", + "example": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "id": { + "description": "Gateway ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "ipv4": { + "description": "Tunnel IPv4 address (see last_seen_remote_ip for the public IP)", + "example": "100.64.0.1", + "type": "string" + }, + "ipv6": { + "description": "Tunnel IPv6 address (see last_seen_remote_ip for the public IP)", + "example": "fd00:2021:1111::1", + "type": "string" + }, + "last_seen_at": { + "description": "Timestamp of the latest connection", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "last_seen_remote_ip": { + "description": "Remote IP from the latest session", + "example": "198.51.100.10", + "nullable": true, + "type": "string" + }, + "last_seen_remote_ip_location_city": { + "description": "Remote IP city from the latest session", + "example": "San Francisco", + "nullable": true, + "type": "string" + }, + "last_seen_remote_ip_location_lat": { + "description": "Remote IP latitude from the latest session", + "example": 37.7749, + "nullable": true, + "type": "number" + }, + "last_seen_remote_ip_location_lon": { + "description": "Remote IP longitude from the latest session", + "example": -122.4194, + "nullable": true, + "type": "number" + }, + "last_seen_remote_ip_location_region": { + "description": "Remote IP region from the latest session", + "example": "US-CA", + "nullable": true, + "type": "string" + }, + "last_seen_user_agent": { + "description": "User agent from the latest session", + "example": "Linux/6.1.0 connlib/1.5.0", + "nullable": true, + "type": "string" + }, + "last_seen_version": { + "description": "Gateway version from the latest session", + "example": "1.5.0", + "nullable": true, + "type": "string" + }, + "name": { + "description": "Gateway Name", + "example": "vpc-us-east", + "type": "string" + }, + "online": { + "description": "Online status of Gateway", + "example": true, + "type": "boolean" + }, + "public_key": { + "description": "WireGuard public key from the latest session", + "example": "WdKAyoA45xJllRUYnFhI5+Y4EjSTs50MzYYHfrIhVAc=", + "nullable": true, + "type": "string" + }, + "rotated_at": { + "description": "When the token identified by `gateway_token_id` was rotated out. Null in the normal case. When set, a replacement token has been minted and this one stays valid only until the Gateway connects with the replacement or 4 hours elapse from this timestamp, whichever comes first - so a non-null value means a rotation is pending and the Gateway has not picked it up yet.", + "format": "date-time", + "nullable": true, + "type": "string" + } + }, + "required": [ + "gateway_token_id", + "id", + "ipv4", + "ipv6", + "last_seen_at", + "last_seen_remote_ip", + "last_seen_remote_ip_location_city", + "last_seen_remote_ip_location_lat", + "last_seen_remote_ip_location_lon", + "last_seen_remote_ip_location_region", + "last_seen_user_agent", + "last_seen_version", + "name", + "online", + "public_key", + "rotated_at" + ], + "title": "Gateway", + "type": "object" + }, + "PoolMemberPutRequest": { + "description": "PUT body replacing the pool's entire membership. Any Client not named\nhere is removed from the pool.\n", + "properties": { + "pool_members": { + "example": [ + { + "device_id": "7cb89288-1fb3-433e-a522-2d087e45988d" + }, + { + "device_id": "cc9f561a-444d-4083-ab38-0abc6cf2314c" + } + ], + "items": { + "properties": { + "device_id": { + "description": "Client ID", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "device_id" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "pool_members" + ], + "title": "PoolMemberPutRequest", + "type": "object" + }, + "GatewayToken": { + "description": "Gateway Token", + "properties": { + "id": { + "description": "Gateway Token ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "token": { + "description": "Gateway Token", + "example": "secret-token-here", + "type": "string" + } + }, + "required": [ + "id", + "token" + ], + "title": "GatewayToken", + "type": "object" + }, + "SiteID": { + "description": "Site to connect the Resource to. Required. The Internet Site is reserved for the Internet Resource and cannot be used.", + "example": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "format": "uuid", + "nullable": true, + "title": "SiteID", + "type": "string" + }, + "Group": { + "description": "Group", + "properties": { + "directory_id": { + "description": "Directory ID this group belongs to", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "email": { + "description": "Group email address for synced groups", + "nullable": true, + "type": "string" + }, + "entity_type": { + "description": "Entity type", + "enum": [ + "group", + "org_unit" + ], + "example": "group", + "type": "string" + }, + "id": { + "description": "Group ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "idp_id": { + "description": "Identity provider ID for synced groups", + "nullable": true, + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2024-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + }, + "name": { + "description": "Group Name", + "example": "Engineering", + "type": "string" + }, + "synced_at": { + "description": "Last sync timestamp for synced groups", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "updated_at": { + "description": "Last update timestamp", + "example": "2024-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "directory_id", + "email", + "entity_type", + "id", + "idp_id", + "inserted_at", + "name", + "synced_at", + "updated_at" + ], + "title": "Group", + "type": "object" + }, + "ProblemDetails": { + "description": "RFC 9457 (Problem Details for HTTP APIs) error response.", + "properties": { + "detail": { + "description": "Human-readable explanation specific to this occurrence of the problem.", + "example": "The requested resource could not be found.", + "type": "string" + }, + "status": { + "description": "HTTP status code.", + "example": 404, + "type": "integer" + }, + "title": { + "description": "Short, human-readable summary of the problem type (the HTTP status phrase).", + "example": "Not Found", + "type": "string" + }, + "type": { + "description": "URI identifying the problem type. Always \"about:blank\" for now.", + "example": "about:blank", + "type": "string" + } + }, + "required": [ + "type", + "title", + "status" + ], + "title": "ProblemDetails", + "type": "object" + }, + "SantaPostureProviderListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/SantaPostureProvider" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "SantaPostureProviderListResponse", + "type": "object" + }, + "ResourceUpdateRequest": { + "description": "PATCH/PUT body for updating a Resource. All fields are optional; omitted fields keep their current value.", + "properties": { + "resource": { + "properties": { + "address": { + "description": "Resource address.", + "example": "10.0.0.10", + "nullable": true, + "type": "string" + }, + "address_description": { + "description": "Resource address description", + "example": "Production Database", + "nullable": true, + "type": "string" + }, + "filters": { + "description": "Traffic filters restricting the protocols and ports the Resource exposes", + "example": [ + { + "ports": [ + "5432" + ], + "protocol": "tcp" + } + ], + "items": { + "$ref": "#/components/schemas/ResourceFilter" + }, + "type": "array" + }, + "ip_stack": { + "description": "IP stack type. Only supported for DNS resources.", + "enum": [ + "ipv4_only", + "ipv6_only", + "dual" + ], + "nullable": true, + "type": "string" + }, + "name": { + "description": "Resource name", + "example": "Prod DB", + "type": "string" + }, + "site_id": { + "description": "Site to connect the Resource to. Required. The Internet Site is reserved for the Internet Resource and cannot be used.", + "example": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "format": "uuid", + "nullable": true, + "title": "SiteID", + "type": "string" + }, + "type": { + "description": "Resource type. `internet` is accepted only in the Internet Site. `static_device_pool` is accepted only on a Resource that already is one.", + "enum": [ + "cidr", + "ip", + "dns", + "internet", + "static_device_pool" + ], + "example": "ip", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "resource" + ], + "title": "ResourceUpdateRequest", + "type": "object" + }, + "ResourceFilter": { + "description": "Traffic filter restricting the protocols and ports the Resource exposes", + "properties": { + "ports": { + "description": "Port numbers or ranges (e.g. `80` or `8000 - 9000`) the filter allows. Not applicable to `icmp`.", + "example": [ + "80", + "443", + "8000 - 9000" + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "protocol": { + "description": "Transport protocol the filter applies to", + "enum": [ + "tcp", + "udp", + "icmp" + ], + "example": "tcp", + "type": "string" + } + }, + "required": [ + "protocol" + ], + "title": "ResourceFilter", + "type": "object" + }, + "ActorCreateRequest": { + "description": "POST body for creating an Actor", + "properties": { + "actor": { + "properties": { + "allow_email_otp_sign_in": { + "default": false, + "description": "Allow Email OTP Sign In", + "example": false, + "type": "boolean" + }, + "email": { + "description": "Actor Email. Optional for service accounts.", + "example": "joe.user@example.com", + "nullable": true, + "type": "string" + }, + "name": { + "description": "Actor Name", + "example": "Joe User", + "type": "string" + }, + "type": { + "description": "Actor Type", + "enum": [ + "account_user", + "account_admin_user", + "service_account" + ], + "example": "account_admin_user", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "type": "object" + } + }, + "required": [ + "actor" + ], + "title": "ActorCreateRequest", + "type": "object" + }, + "GatewayUpdate": { + "description": "Update schema for a single Gateway", + "properties": { + "name": { + "description": "Gateway Name", + "example": "vpc-us-east", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "GatewayUpdate", + "type": "object" + }, + "MembershipPatchRequest": { + "description": "PATCH body for updating Memberships", + "properties": { + "memberships": { + "properties": { + "add": { + "description": "Array of Actor IDs", + "example": [ + "4ddfa557-7dfc-484f-894c-2024ec3fe9f7" + ], + "items": { + "description": "Actor ID", + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "remove": { + "description": "Array of Actor IDs", + "example": [ + "89d22f71-939d-442d-b148-897b730adfb4" + ], + "items": { + "description": "Actor ID", + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + }, + "required": [ + "memberships" + ], + "title": "MembershipPatchRequest", + "type": "object" + }, + "LogResponse": { + "description": "Response schema for a single Log entry.", + "properties": { + "data": { + "$ref": "#/components/schemas/Log" + } + }, + "title": "LogResponse", + "type": "object" + }, + "SantaPostureProviderResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/SantaPostureProvider" + } + }, + "title": "SantaPostureProviderResponse", + "type": "object" + }, + "ClientResponse": { + "description": "Response schema for single Client", + "properties": { + "data": { + "$ref": "#/components/schemas/Client" + } + }, + "title": "ClientResponse", + "type": "object" + }, + "PoolMember": { + "description": "A Client belonging to a static device pool Resource", + "properties": { + "id": { + "description": "Client ID", + "example": "7cb89288-1fb3-433e-a522-2d087e45988d", + "format": "uuid", + "type": "string" + }, + "last_seen_at": { + "description": "Last time the Client was seen", + "example": "2024-01-15T10:30:00Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "name": { + "description": "Client Name", + "example": "jane-laptop", + "type": "string" + } + }, + "required": [ + "id", + "last_seen_at", + "name" + ], + "title": "PoolMember", + "type": "object" + }, + "Policy": { + "description": "Policy", + "properties": { + "conditions": { + "description": "Conditions that must be satisfied for the Policy to grant access", + "example": [ + { + "operator": "is_in", + "property": "remote_ip_location_region", + "values": [ + "US", + "CA" + ] + } + ], + "items": { + "$ref": "#/components/schemas/PolicyCondition" + }, + "type": "array" + }, + "description": { + "description": "Policy Description", + "example": "Policy to allow something", + "nullable": true, + "type": "string" + }, + "flow_log_uploads_enabled": { + "description": "Whether flow logs are reported for connections authorized by this Policy", + "example": true, + "type": "boolean" + }, + "group_id": { + "description": "Group ID. Null if the Group was deleted during directory sync; it is relinked automatically if the Group reappears on a subsequent sync.", + "example": "88eae9ce-9179-48c6-8430-770e38dd4775", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "id": { + "description": "Policy ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "is_disabled": { + "description": "Whether the Policy is disabled. A disabled Policy grants no access but is otherwise retained.", + "example": false, + "type": "boolean" + }, + "resource_id": { + "description": "Resource ID", + "example": "a9f60587-793c-46ae-8525-597f43ab2fb1", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "conditions", + "description", + "flow_log_uploads_enabled", + "group_id", + "id", + "is_disabled", + "resource_id" + ], + "title": "Policy", + "type": "object" + }, + "AccountResponse": { + "description": "Response schema for Account", + "properties": { + "data": { + "$ref": "#/components/schemas/Account" + } + }, + "required": [ + "data" + ], + "title": "AccountResponse", + "type": "object" + }, + "EmailOTPAuthProvider": { + "description": "Email OTP Auth Provider", + "properties": { + "account_id": { + "description": "Account ID", + "example": "5e6f7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f", + "format": "uuid", + "type": "string" + }, + "client_session_lifetime_secs": { + "description": "Client session lifetime in seconds. Null when the account default applies.", + "example": 604800, + "nullable": true, + "type": "integer" + }, + "context": { + "description": "Context", + "enum": [ + "clients_and_portal", + "clients_only", + "portal_only" + ], + "example": "clients_and_portal", + "type": "string" + }, + "id": { + "description": "Provider ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "description": "Whether provider is disabled", + "example": false, + "type": "boolean" + }, + "issuer": { + "description": "Issuer", + "example": "firezone", + "type": "string" + }, + "name": { + "description": "Provider name", + "example": "Email OTP", + "type": "string" + }, + "portal_session_lifetime_secs": { + "description": "Portal session lifetime in seconds. Null when the account default applies.", + "example": 28800, + "nullable": true, + "type": "integer" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "client_session_lifetime_secs", + "context", + "id", + "inserted_at", + "is_disabled", + "issuer", + "name", + "portal_session_lifetime_secs", + "updated_at" + ], + "title": "EmailOTPAuthProvider", + "type": "object" + }, + "PolicyCondition": { + "description": "A condition that must be satisfied for the Policy to grant access.\n\nAll conditions on a Policy must evaluate to true for access to be\ngranted. A condition is made up of a `property`, an `operator`, and a\nlist of `values`. The valid operators and the meaning of `values`\ndepend on the `property`:\n\n* `remote_ip_location_region` with `is_in` / `is_not_in`: `values` are\n ISO 3166-1 alpha-2 country codes, e.g. `[\"US\", \"CA\"]`.\n* `remote_ip` with `is_in_cidr` / `is_not_in_cidr`: `values` are CIDR\n ranges (IPv4 or IPv6), e.g. `[\"10.0.0.0/8\", \"2607:f8b0::/32\"]`.\n* `auth_provider_id` with `is_in` / `is_not_in`: `values` are\n authentication provider IDs (UUIDs).\n* `current_utc_datetime` with `is_in_day_of_week_time_ranges`: each\n value is a `DAY/TIME_RANGES/TIMEZONE` string where `DAY` is one of\n `M T W R F S U` (Monday through Sunday), `TIME_RANGES` is a\n comma-separated list of `HH:MM-HH:MM` ranges, and `TIMEZONE` is an\n IANA timezone name, e.g. `\"M/09:00-17:00/America/New_York\"`.\n* `client_verified` with `is`: `values` is a single-element list\n containing `\"true\"` or `\"false\"`.\n", + "properties": { + "operator": { + "description": "How the values are compared against the property", + "enum": [ + "is_in", + "is_not_in", + "is_in_cidr", + "is_not_in_cidr", + "is_in_day_of_week_time_ranges", + "is" + ], + "example": "is_in", + "type": "string" + }, + "property": { + "description": "The attribute of the connection being matched against", + "enum": [ + "remote_ip_location_region", + "remote_ip", + "auth_provider_id", + "current_utc_datetime", + "client_verified" + ], + "example": "remote_ip_location_region", + "type": "string" + }, + "values": { + "description": "The values to compare against, interpreted per the property", + "example": [ + "US", + "CA" + ], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "property", + "operator", + "values" + ], + "title": "PolicyCondition", + "type": "object" + }, + "ValidationProblemDetails": { + "description": "RFC 9457 error response for request validation failures. The `validation_errors` member maps each invalid field to a list of human-readable messages. Errors on nested objects and on the items of a list are reported under the same shape, keyed by field name or list index. It is omitted when the failure is not attributable to individual fields.", + "properties": { + "detail": { + "example": "The request body failed validation.", + "type": "string" + }, + "status": { + "example": 422, + "type": "integer" + }, + "title": { + "example": "Unprocessable Content", + "type": "string" + }, + "type": { + "example": "about:blank", + "type": "string" + }, + "validation_errors": { + "$ref": "#/components/schemas/ValidationErrors" + } + }, + "required": [ + "type", + "title", + "status" + ], + "title": "ValidationProblemDetails", + "type": "object" + }, + "Account": { + "description": "Account schema", + "properties": { + "id": { + "description": "Account ID", + "format": "uuid", + "type": "string" + }, + "key": { + "description": "6-character account key", + "maxLength": 6, + "minLength": 6, + "type": "string" + }, + "legal_name": { + "description": "Account legal name", + "type": "string" + }, + "limits": { + "$ref": "#/components/schemas/AccountLimits" + }, + "name": { + "description": "Account name", + "type": "string" + }, + "slug": { + "description": "Account slug", + "type": "string" + } + }, + "required": [ + "id", + "key", + "legal_name", + "limits", + "name", + "slug" + ], + "title": "Account", + "type": "object" + }, + "SentinelOneDeviceListResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/SentinelOneDevice" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "SentinelOneDeviceListResponse", + "type": "object" + }, + "AccountLimits": { + "description": "Account limits and usage information", + "properties": { + "account_admin_users": { + "$ref": "#/components/schemas/AccountLimit" + }, + "monthly_active_users": { + "$ref": "#/components/schemas/AccountLimit" + }, + "service_accounts": { + "$ref": "#/components/schemas/AccountLimit" + }, + "sites": { + "$ref": "#/components/schemas/AccountLimit" + }, + "users": { + "$ref": "#/components/schemas/AccountLimit" + } + }, + "title": "AccountLimits", + "type": "object" + }, + "GoogleAuthProvider": { + "description": "Google Auth Provider", + "properties": { + "account_id": { + "description": "Account ID", + "example": "5e6f7d8c-9b0a-1c2d-3e4f-5a6b7c8d9e0f", + "format": "uuid", + "type": "string" + }, + "client_session_lifetime_secs": { + "description": "Client session lifetime in seconds. Null when the account default applies.", + "example": 604800, + "nullable": true, + "type": "integer" + }, + "context": { + "description": "Context", + "enum": [ + "clients_and_portal", + "clients_only", + "portal_only" + ], + "example": "clients_and_portal", + "type": "string" + }, + "id": { + "description": "Provider ID", + "example": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "description": "Creation timestamp", + "example": "2025-01-01T00:00:00Z", + "format": "date-time", + "type": "string" + }, + "is_default": { + "description": "Whether provider is default", + "example": true, + "type": "boolean" + }, + "is_disabled": { + "description": "Whether provider is disabled", + "example": false, + "type": "boolean" + }, + "issuer": { + "description": "Issuer", + "example": "https://accounts.google.com", + "type": "string" + }, + "name": { + "description": "Provider name", + "example": "Google", + "type": "string" + }, + "portal_session_lifetime_secs": { + "description": "Portal session lifetime in seconds. Null when the account default applies.", + "example": 28800, + "nullable": true, + "type": "integer" + }, + "updated_at": { + "description": "Update timestamp", + "example": "2025-01-15T10:30:00Z", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "client_session_lifetime_secs", + "context", + "id", + "inserted_at", + "is_default", + "is_disabled", + "issuer", + "name", + "portal_session_lifetime_secs", + "updated_at" + ], + "title": "GoogleAuthProvider", + "type": "object" + }, + "GroupUpdateRequest": { + "description": "PATCH/PUT body for updating a Group. All fields are optional; omitted fields keep their current value.", + "properties": { + "group": { + "properties": { + "name": { + "description": "Group Name", + "example": "Engineering", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "group" + ], + "title": "GroupUpdateRequest", + "type": "object" + }, + "APIRequestLog": { + "description": "A single API Request Log entry, recording one authenticated REST API\nrequest.\n", + "properties": { + "actor_id": { + "description": "ID of the API Client actor.", + "example": "84e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "api_token_id": { + "description": "ID of the API token used.", + "example": "44e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "content_length": { + "description": "Value of the Content-Length request header, when present.", + "nullable": true, + "type": "integer" + }, + "ip": { + "example": "189.172.73.1", + "type": "string" + }, + "ip_city": { + "example": "Mexico City", + "nullable": true, + "type": "string" + }, + "ip_lat": { + "example": 19.4326, + "nullable": true, + "type": "number" + }, + "ip_lon": { + "example": -99.1332, + "nullable": true, + "type": "number" + }, + "ip_region": { + "example": "MX", + "nullable": true, + "type": "string" + }, + "log_id": { + "description": "Opaque identifier for the API Request Log entry. A 24-character\nlowercase hexadecimal string starting with `a`.\n", + "example": "a00060db0c2c8eb400000000", + "type": "string" + }, + "method": { + "description": "HTTP request method.", + "example": "GET", + "type": "string" + }, + "path": { + "description": "HTTP request path.", + "example": "/clients", + "type": "string" + }, + "request_id": { + "description": "Request ID assigned by the server, for correlating with server logs.", + "example": "GBKkV1jUWuW2sJoAACkB", + "type": "string" + }, + "timestamp": { + "description": "RFC 3339 timestamp identifying when the request was received.", + "example": "2026-05-26T12:34:56.789Z", + "format": "date-time", + "type": "string" + }, + "type": { + "enum": [ + "api_request" + ], + "example": "api_request", + "type": "string" + }, + "user_agent": { + "example": "curl/8.7.1", + "nullable": true, + "type": "string" + } + }, + "required": [ + "actor_id", + "api_token_id", + "content_length", + "ip", + "ip_city", + "ip_lat", + "ip_lon", + "ip_region", + "log_id", + "method", + "path", + "request_id", + "timestamp", + "type", + "user_agent" + ], + "title": "APIRequestLog", + "type": "object" + }, + "PoolMemberListResponse": { + "description": "Response schema for Pool Members", + "properties": { + "data": { + "description": "Pool member details", + "items": { + "$ref": "#/components/schemas/PoolMember" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "PoolMemberListResponse", + "type": "object" + }, + "SiteUpdateRequest": { + "description": "PATCH/PUT body for updating a Site. All fields are optional; omitted fields keep their current value.", + "properties": { + "site": { + "properties": { + "name": { + "description": "Site Name", + "example": "vpc-us-east", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "site" + ], + "title": "SiteUpdateRequest", + "type": "object" + }, + "SentinelOnePostureProvider": { + "description": "SentinelOne posture provider", + "properties": { + "account_id": { + "format": "uuid", + "type": "string" + }, + "disabled_reason": { + "nullable": true, + "type": "string" + }, + "error_message": { + "nullable": true, + "type": "string" + }, + "errored_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "type": "string" + }, + "is_disabled": { + "type": "boolean" + }, + "is_verified": { + "type": "boolean" + }, + "management_url": { + "format": "uri", + "type": "string" + }, + "name": { + "type": "string" + }, + "synced_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "type": { + "enum": [ + "sentinelone" + ], + "type": "string" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "account_id", + "disabled_reason", + "error_message", + "errored_at", + "id", + "inserted_at", + "is_disabled", + "is_verified", + "management_url", + "name", + "synced_at", + "type", + "updated_at" + ], + "title": "SentinelOnePostureProvider", + "type": "object" + }, + "PoolMemberPatchRequest": { + "description": "PATCH body for adding and removing individual pool members. Both\noperations are idempotent, and `remove` is applied before `add`, so a\nClient named in both ends up in the pool.\n", + "properties": { + "pool_members": { + "properties": { + "add": { + "description": "Client IDs to add to the pool", + "example": [ + "7cb89288-1fb3-433e-a522-2d087e45988d" + ], + "items": { + "description": "Client ID", + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "remove": { + "description": "Client IDs to remove from the pool", + "example": [ + "cc9f561a-444d-4083-ab38-0abc6cf2314c" + ], + "items": { + "description": "Client ID", + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + }, + "required": [ + "pool_members" + ], + "title": "PoolMemberPatchRequest", + "type": "object" + }, + "FlowLog": { + "description": "A single Flow Log entry, recording one network flow as accounted by one\nof its two endpoints.\n\nBoth endpoints of a flow report it independently, so a flow yields up to\ntwo entries that differ only in `role` and in the counters each side\nobserved. Every other field is oriented from the initiator regardless of\nwhich side reported the entry: `inner_src_*` is always the initiator,\n`inner_dst_*` always the responder, and `tx_*` always counts\ninitiator-to-responder traffic. `outers` records each outer network path\nin the order it was observed. Comparing the two entries of a flow is how\nreported traffic is cross-checked.\n", + "properties": { + "log_id": { + "description": "Opaque identifier for the Flow Log entry. A 24-character lowercase\nhexadecimal string starting with `f`.\n", + "example": "f00060db0c2c8eb400000000", + "type": "string" + }, + "initiator_device_id": { + "description": "ID of the Client that opened the flow. Always a Client.", + "example": "11e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "last_packet": { + "description": "When the last packet was seen. Null while the flow is open.", + "example": "2026-05-26T12:33:58.000Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "inner_dst_ip": { + "description": "Tunnel IP of the responder, on both entries of the flow.", + "example": "10.0.0.5", + "type": "string" + }, + "initiator_auth_provider_id": { + "description": "ID of the Auth Provider the initiating Client authenticated with.\n", + "example": "7b2c1e40-9f3a-4d21-8c5e-1a2b3c4d5e6f", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "initiator_device_uuid": { + "description": "Device UUID reported by the initiating Client.", + "example": "0C4A8D24-FA9F-4E56-9B57-40D0D46A245E", + "nullable": true, + "type": "string" + }, + "flow_end": { + "description": "RFC 3339 timestamp of when the flow ended. Null while the flow is open.", + "example": "2026-05-26T12:34:00.000Z", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "initiator_device_identifier_for_vendor": { + "description": "Vendor identifier reported by the initiating Client.", + "nullable": true, + "type": "string" + }, + "policy_authorization_id": { + "description": "ID of the Policy Authorization that permitted the flow.", + "example": "6fa1d58f-0289-42e6-a3ba-7edfa46ee2d5", + "format": "uuid", + "type": "string" + }, + "tx_bytes": { + "description": "Bytes sent initiator-to-responder, as counted by the reporting side. Null while the flow is open.", + "example": 20480, + "nullable": true, + "type": "integer" + }, + "initiator_client_version": { + "description": "Firezone Client version reported by the initiating Client.", + "example": "1.5.1", + "nullable": true, + "type": "string" + }, + "type": { + "enum": [ + "flow" + ], + "example": "flow", + "type": "string" + }, + "rx_packets": { + "description": "Packets sent responder-to-initiator, as counted by the reporting side. Null while the flow is open.", + "example": 100, + "nullable": true, + "type": "integer" + }, + "initiator_device_os_name": { + "description": "Operating system reported by the initiating Client.", + "example": "macOS", + "nullable": true, + "type": "string" + }, + "timestamp": { + "description": "RFC 3339 timestamp identifying when the flow was ingested.", + "example": "2026-05-26T12:34:56.789Z", + "format": "date-time", + "type": "string" + }, + "role": { + "description": "Which of the two endpoints reported this entry: `initiator` means\n`initiator_device_id` wrote it, `responder` means\n`responder_device_id` did. Gateways always report `responder`;\n Clients report either role.\n", + "enum": [ + "initiator", + "responder" + ], + "example": "responder", + "type": "string" + }, + "tx_packets": { + "description": "Packets sent initiator-to-responder, as counted by the reporting side. Null while the flow is open.", + "example": 80, + "nullable": true, + "type": "integer" + }, + "inner_dst_port": { + "example": 443, + "type": "integer" + }, + "initiator_device_firebase_installation_id": { + "description": "Firebase installation ID reported by the initiating Client.", + "nullable": true, + "type": "string" + }, + "inner_src_ip": { + "description": "Tunnel IP of the initiator, on both entries of the flow.", + "example": "100.64.0.1", + "type": "string" + }, + "responder_device_id": { + "description": "ID of the device the flow was opened to: the Gateway serving the\nResource, or the receiving Client for device-to-device flows.\n", + "example": "9d3a1c40-5f2b-4c8e-9a71-0b6d4e2f8c13", + "format": "uuid", + "type": "string" + }, + "initiator_actor_email": { + "example": "user@example.com", + "nullable": true, + "type": "string" + }, + "protocol": { + "description": "Transport protocol of the flow.", + "enum": [ + "tcp", + "udp" + ], + "example": "tcp", + "type": "string" + }, + "authorization_expires_at": { + "description": "When the Policy Authorization expires.", + "example": "2026-05-26T20:29:00.000Z", + "format": "date-time", + "type": "string" + }, + "outers": { + "description": "Outer network paths in observation order. Null while the flow is open;\nthe close report replaces it with the complete array. Source IP and\nport must either both be populated or both be absent/null. The two\nentries of a flow can disagree whenever NAT or a relay sits between\nthe peers.\n", + "example": [ + { + "dst_ip": "198.51.100.5", + "dst_port": 51820, + "src_ip": "203.0.113.10", + "src_port": 51820 + } + ], + "items": { + "properties": { + "dst_ip": { + "type": "string" + }, + "dst_port": { + "type": "integer" + }, + "path_activated_at": { + "description": "RFC 3339 timestamp of when the path was selected.", + "format": "date-time", + "nullable": true, + "type": "string" + }, + "src_ip": { + "nullable": true, + "type": "string" + }, + "src_port": { + "nullable": true, + "type": "integer" + } + }, + "required": [ + "dst_ip", + "dst_port" + ], + "type": "object" + }, + "minItems": 1, + "nullable": true, + "type": "array" + }, + "resource_id": { + "description": "ID of the Resource accessed.", + "example": "44e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "type": "string" + }, + "inner_src_port": { + "example": 54321, + "type": "integer" + }, + "rx_bytes": { + "description": "Bytes sent responder-to-initiator, as counted by the reporting side. Null while the flow is open.", + "example": 102400, + "nullable": true, + "type": "integer" + }, + "authorized_at": { + "description": "When access to the Resource was authorized.", + "example": "2026-05-26T12:29:00.000Z", + "format": "date-time", + "type": "string" + }, + "flow_start": { + "description": "RFC 3339 timestamp of when the flow began. The `begin`/`end`\nwindow matches flows whose [`flow_start`, `flow_end`) range\noverlaps it.\n", + "example": "2026-05-26T12:30:00.000Z", + "format": "date-time", + "type": "string" + }, + "initiator_device_os_version": { + "description": "Operating system version reported by the initiating Client.", + "example": "15.5", + "nullable": true, + "type": "string" + }, + "inner_domain": { + "description": "Domain name for flows to DNS Resources.", + "example": "gitlab.company.com", + "nullable": true, + "type": "string" + }, + "initiator_actor_id": { + "description": "ID of the Actor who opened the flow. This is always the initiating\nClient's Actor. A Gateway has no Actor, and for device-to-device\nflows the receiving Client's own Actor is not recorded here.\n", + "example": "84e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "format": "uuid", + "nullable": true, + "type": "string" + }, + "initiator_actor_name": { + "example": "John Doe", + "nullable": true, + "type": "string" + }, + "resource_address": { + "description": "Resource address, when the Resource type has one.", + "example": "gitlab.company.com", + "nullable": true, + "type": "string" + }, + "initiator_device_serial": { + "description": "Device serial number reported by the initiating Client.", + "example": "C02ABC123", + "nullable": true, + "type": "string" + }, + "resource_name": { + "example": "GitLab", + "type": "string" + }, + "policy_id": { + "description": "ID of the Policy that permitted the flow.", + "example": "46f997d1-77c8-4936-8655-8f050dffbfa4", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "authorization_expires_at", + "authorized_at", + "flow_end", + "flow_start", + "initiator_actor_email", + "initiator_actor_id", + "initiator_actor_name", + "initiator_auth_provider_id", + "initiator_client_version", + "initiator_device_firebase_installation_id", + "initiator_device_id", + "initiator_device_identifier_for_vendor", + "initiator_device_os_name", + "initiator_device_os_version", + "initiator_device_serial", + "initiator_device_uuid", + "inner_domain", + "inner_dst_ip", + "inner_dst_port", + "inner_src_ip", + "inner_src_port", + "last_packet", + "log_id", + "outers", + "policy_authorization_id", + "policy_id", + "protocol", + "resource_address", + "resource_id", + "resource_name", + "responder_device_id", + "role", + "rx_bytes", + "rx_packets", + "timestamp", + "tx_bytes", + "tx_packets", + "type" + ], + "title": "FlowLog", + "type": "object" + }, + "SiteListResponse": { + "description": "Response schema for multiple Sites", + "properties": { + "data": { + "description": "Site details", + "items": { + "$ref": "#/components/schemas/Site" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "SiteListResponse", + "type": "object" + }, + "PolicyListResponse": { + "description": "Response schema for multiple Policies", + "properties": { + "data": { + "description": "Policy details", + "items": { + "$ref": "#/components/schemas/Policy" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "PolicyListResponse", + "type": "object" + }, + "EmailOTPAuthProviderListResponse": { + "description": "Response schema for multiple Email OTP Auth Providers", + "properties": { + "data": { + "description": "Email OTP Auth Provider details", + "items": { + "$ref": "#/components/schemas/EmailOTPAuthProvider" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "EmailOTPAuthProviderListResponse", + "type": "object" + }, + "SiteCreateRequest": { + "description": "POST body for creating a Site", + "properties": { + "site": { + "properties": { + "name": { + "description": "Site Name", + "example": "vpc-us-east", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + }, + "required": [ + "site" + ], + "title": "SiteCreateRequest", + "type": "object" + }, + "ResourceListResponse": { + "description": "Response schema for multiple Resources", + "properties": { + "data": { + "description": "Resource details", + "items": { + "$ref": "#/components/schemas/Resource" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/PaginationMetadata" + } + }, + "title": "ResourceListResponse", + "type": "object" + }, + "IntuneDevice": { + "description": "Device synced from Microsoft Intune", + "properties": { + "device_registration_state": { + "nullable": true, + "type": "string" + }, + "attestation_operating_system_rev_list_info": { + "nullable": true, + "type": "string" + }, + "entra_registered": { + "nullable": true, + "type": "boolean" + }, + "attestation_boot_app_security_version": { + "nullable": true, + "type": "string" + }, + "operating_system": { + "nullable": true, + "type": "string" + }, + "is_supervised": { + "nullable": true, + "type": "boolean" + }, + "notes": { + "nullable": true, + "type": "string" + }, + "free_storage_space_bytes": { + "nullable": true, + "type": "integer" + }, + "attestation_issued_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "config_manager_compliance_policy": { + "nullable": true, + "type": "boolean" + }, + "user_display_name": { + "nullable": true, + "type": "string" + }, + "config_manager_device_configuration": { + "nullable": true, + "type": "boolean" + }, + "config_manager_inventory": { + "nullable": true, + "type": "boolean" + }, + "updated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "enrolled_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "total_storage_space_bytes": { + "nullable": true, + "type": "integer" + }, + "device_action_results": { + "items": { + "type": "object" + }, + "nullable": true, + "type": "array" + }, + "attestation_operating_system_kernel_debugging": { + "nullable": true, + "type": "string" + }, + "ethernet_mac_address": { + "nullable": true, + "type": "string" + }, + "synced_at": { + "format": "date-time", + "nullable": false, + "type": "string" + }, + "inserted_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "attestation_health_status_mismatch_info": { + "nullable": true, + "type": "string" + }, + "intune_id": { + "nullable": false, + "type": "string" + }, + "management_certificate_expires_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "compliance_state": { + "nullable": true, + "type": "string" + }, + "require_user_enrollment_approval": { + "nullable": true, + "type": "boolean" + }, + "attestation_code_integrity_check_version": { + "nullable": true, + "type": "string" + }, + "attestation_pcr0": { + "nullable": true, + "type": "string" + }, + "attestation_boot_revision_list_info": { + "nullable": true, + "type": "string" + }, + "attestation_reset_count": { + "nullable": true, + "type": "integer" + }, + "attestation_safe_mode": { + "nullable": true, + "type": "string" + }, + "managed_device_owner_type": { + "nullable": true, + "type": "string" + }, + "serial_number": { + "nullable": true, + "type": "string" + }, + "attestation_secure_boot": { + "nullable": true, + "type": "string" + }, + "device_category_display_name": { + "nullable": true, + "type": "string" + }, + "attestation_code_integrity_policy": { + "nullable": true, + "type": "string" + }, + "attestation_virtual_secure_mode": { + "nullable": true, + "type": "string" + }, + "last_sync_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "attestation_bit_locker_status": { + "nullable": true, + "type": "string" + }, + "is_encrypted": { + "nullable": true, + "type": "boolean" + }, + "eas_activated": { + "nullable": true, + "type": "boolean" + }, + "manufacturer": { + "nullable": true, + "type": "string" + }, + "eas_activated_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "account_id": { + "format": "uuid", + "nullable": false, + "type": "string" + }, + "attestation_early_launch_anti_malware_driver_protection": { + "nullable": true, + "type": "string" + }, + "attestation_last_update_date_time": { + "nullable": true, + "type": "string" + }, + "management_agent": { + "nullable": true, + "type": "string" + }, + "attestation_tpm_version": { + "nullable": true, + "type": "string" + }, + "management_state": { + "nullable": true, + "type": "string" + }, + "wifi_mac_address": { + "nullable": true, + "type": "string" + }, + "attestation_code_integrity": { + "nullable": true, + "type": "string" + }, + "user_principal_name": { + "nullable": true, + "type": "string" + }, + "entra_device_id": { + "nullable": true, + "type": "string" + }, + "android_security_patch_level": { + "nullable": true, + "type": "string" + }, + "attestation_supported_status": { + "nullable": true, + "type": "string" + }, + "attestation_pcr_hash_algorithm": { + "nullable": true, + "type": "string" + }, + "device_enrollment_type": { + "nullable": true, + "type": "string" + }, + "subscriber_carrier": { + "nullable": true, + "type": "string" + }, + "enrollment_profile_name": { + "nullable": true, + "type": "string" + }, + "model": { + "nullable": true, + "type": "string" + }, + "attestation_boot_manager_version": { + "nullable": true, + "type": "string" + }, + "partner_reported_threat_state": { + "nullable": true, + "type": "string" + }, + "config_manager_modern_apps": { + "nullable": true, + "type": "boolean" + }, + "user_id": { + "nullable": true, + "type": "string" + }, + "jail_broken": { + "nullable": true, + "type": "string" + }, + "eas_device_id": { + "nullable": true, + "type": "string" + }, + "exchange_last_successful_sync_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "attestation_content_version": { + "nullable": true, + "type": "string" + }, + "exchange_access_state": { + "nullable": true, + "type": "string" + }, + "managed_device_name": { + "nullable": true, + "type": "string" + }, + "udid": { + "nullable": true, + "type": "string" + }, + "attestation_secure_boot_config_policy_fingerprint": { + "nullable": true, + "type": "string" + }, + "attestation_identity_key": { + "nullable": true, + "type": "string" + }, + "attestation_boot_manager_security_version": { + "nullable": true, + "type": "string" + }, + "compliance_grace_period_expiration_at": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "exchange_access_state_reason": { + "nullable": true, + "type": "string" + }, + "attestation_windows_pe": { + "nullable": true, + "type": "string" + }, + "attestation_status": { + "nullable": true, + "type": "string" + }, + "iccid": { + "nullable": true, + "type": "string" + }, + "meid": { + "nullable": true, + "type": "string" + }, + "attestation_test_signing": { + "nullable": true, + "type": "string" + }, + "attestation_boot_debugging": { + "nullable": true, + "type": "string" + }, + "attestation_data_execution_policy": { + "nullable": true, + "type": "string" + }, + "phone_number": { + "nullable": true, + "type": "string" + }, + "attestation_restart_count": { + "nullable": true, + "type": "integer" + }, + "imei": { + "nullable": true, + "type": "string" + }, + "os_version": { + "nullable": true, + "type": "string" + }, + "physical_memory_bytes": { + "nullable": true, + "type": "integer" + }, + "config_manager_windows_update_for_business": { + "nullable": true, + "type": "boolean" + }, + "email_address": { + "nullable": true, + "type": "string" + }, + "device_name": { + "nullable": true, + "type": "string" + }, + "attestation_content_namespace_url": { + "nullable": true, + "type": "string" + }, + "posture_provider_id": { + "format": "uuid", + "nullable": false, + "type": "string" + }, + "config_manager_resource_access": { + "nullable": true, + "type": "boolean" + } + }, + "required": [ + "account_id", + "intune_id", + "posture_provider_id", + "device_name", + "managed_device_name", + "serial_number", + "entra_device_id", + "enrollment_profile_name", + "device_category_display_name", + "user_id", + "user_principal_name", + "user_display_name", + "email_address", + "operating_system", + "os_version", + "model", + "manufacturer", + "imei", + "meid", + "iccid", + "udid", + "phone_number", + "subscriber_carrier", + "wifi_mac_address", + "ethernet_mac_address", + "android_security_patch_level", + "total_storage_space_bytes", + "free_storage_space_bytes", + "physical_memory_bytes", + "compliance_state", + "management_state", + "management_agent", + "managed_device_owner_type", + "device_enrollment_type", + "device_registration_state", + "partner_reported_threat_state", + "jail_broken", + "is_encrypted", + "is_supervised", + "entra_registered", + "require_user_enrollment_approval", + "notes", + "eas_activated", + "eas_device_id", + "eas_activated_at", + "exchange_access_state", + "exchange_access_state_reason", + "exchange_last_successful_sync_at", + "config_manager_inventory", + "config_manager_modern_apps", + "config_manager_resource_access", + "config_manager_device_configuration", + "config_manager_compliance_policy", + "config_manager_windows_update_for_business", + "attestation_last_update_date_time", + "attestation_content_namespace_url", + "attestation_status", + "attestation_content_version", + "attestation_issued_at", + "attestation_identity_key", + "attestation_reset_count", + "attestation_restart_count", + "attestation_data_execution_policy", + "attestation_bit_locker_status", + "attestation_boot_manager_version", + "attestation_code_integrity_check_version", + "attestation_secure_boot", + "attestation_boot_debugging", + "attestation_operating_system_kernel_debugging", + "attestation_code_integrity", + "attestation_test_signing", + "attestation_safe_mode", + "attestation_windows_pe", + "attestation_early_launch_anti_malware_driver_protection", + "attestation_virtual_secure_mode", + "attestation_pcr_hash_algorithm", + "attestation_boot_app_security_version", + "attestation_boot_manager_security_version", + "attestation_tpm_version", + "attestation_pcr0", + "attestation_secure_boot_config_policy_fingerprint", + "attestation_code_integrity_policy", + "attestation_boot_revision_list_info", + "attestation_operating_system_rev_list_info", + "attestation_health_status_mismatch_info", + "attestation_supported_status", + "device_action_results", + "enrolled_at", + "last_sync_at", + "compliance_grace_period_expiration_at", + "management_certificate_expires_at", + "synced_at", + "inserted_at", + "updated_at" + ], + "title": "IntuneDevice", + "type": "object" + }, + "FlowLogIngestRequest": { + "additionalProperties": false, + "description": "A batch of flow-log records for one policy authorization.", + "properties": { + "flow_logs": { + "items": { + "$ref": "#/components/schemas/FlowLogIngestRecord" + }, + "maxItems": 10000, + "type": "array" + } + }, + "required": [ + "flow_logs" + ], + "title": "FlowLogIngestRequest", + "type": "object" + }, + "AccountLimit": { + "description": "Account limit with usage information", + "properties": { + "available": { + "description": "Remaining available count", + "type": "integer" + }, + "total": { + "description": "Total allowed count", + "type": "integer" + }, + "used": { + "description": "Current usage count", + "type": "integer" + } + }, + "required": [ + "used", + "available", + "total" + ], + "title": "AccountLimit", + "type": "object" + }, + "IruDeviceResponse": { + "properties": { + "data": { + "$ref": "#/components/schemas/IruDevice" + } + }, + "title": "IruDeviceResponse", + "type": "object" + }, + "OIDCAuthProviderResponse": { + "description": "Response schema for single OIDC Auth Provider", + "properties": { + "data": { + "$ref": "#/components/schemas/OIDCAuthProvider" + } + }, + "title": "OIDCAuthProviderResponse", + "type": "object" + } + }, + "securitySchemes": { + "authorization": { + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "A REST API for configuring your Firezone account.\n", + "title": "Firezone API", + "version": "1.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/groups": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.GroupController.index", + "parameters": [ + { + "description": "Limit Groups returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Groups with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Groups synced from this Directory. Pass an empty string to filter to unsynced (native) Groups only.", + "in": "query", + "name": "directory_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Groups of this entity type", + "example": "group", + "in": "query", + "name": "entity_type", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupListResponse" + } + } + }, + "description": "Group Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Groups", + "tags": [ + "Groups" + ] + }, + "post": { + "callbacks": {}, + "operationId": "PortalAPI.GroupController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupCreateRequest" + } + } + }, + "description": "Group Attributes", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupResponse" + } + } + }, + "description": "Group Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Create Group", + "tags": [ + "Groups" + ] + } + }, + "/actors/{actor_id}/external_identities": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.ExternalIdentityController.index", + "parameters": [ + { + "description": "Actor ID", + "in": "path", + "name": "actor_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Limit External Identities returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalIdentityListResponse" + } + } + }, + "description": "ExternalIdentity List Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List External Identities for an Actor", + "tags": [ + "External Identities" + ] + } + }, + "/clients/{id}": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.ClientController.delete", + "parameters": [ + { + "description": "Client ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientResponse" + } + } + }, + "description": "Client Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete a Client", + "tags": [ + "Clients" + ] + }, + "get": { + "callbacks": {}, + "operationId": "PortalAPI.ClientController.show", + "parameters": [ + { + "description": "Client ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientResponse" + } + } + }, + "description": "Client Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Client", + "tags": [ + "Clients" + ] + }, + "patch": { + "callbacks": {}, + "operationId": "PortalAPI.ClientController.update (2)", + "parameters": [ + { + "description": "Client ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientPutRequest" + } + } + }, + "description": "Client Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientResponse" + } + } + }, + "description": "Client Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update Client", + "tags": [ + "Clients" + ] + }, + "put": { + "callbacks": {}, + "operationId": "PortalAPI.ClientController.update", + "parameters": [ + { + "description": "Client ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientPutRequest" + } + } + }, + "description": "Client Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientResponse" + } + } + }, + "description": "Client Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update Client", + "tags": [ + "Clients" + ] + } + }, + "/sites/{site_id}/gateways/{id}": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.GatewayController.delete", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Gateway ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayResponse" + } + } + }, + "description": "Gateway Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete a Gateway", + "tags": [ + "Gateways" + ] + }, + "get": { + "callbacks": {}, + "operationId": "PortalAPI.GatewayController.show", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Gateway ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayResponse" + } + } + }, + "description": "Gateway Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Gateway", + "tags": [ + "Gateways" + ] + }, + "patch": { + "callbacks": {}, + "description": "Renames a Gateway. Gateways otherwise self-register their configuration (name, IP addresses) on first connect and can only be renamed, not fully edited, via this endpoint.\n", + "operationId": "PortalAPI.GatewayController.update (2)", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Gateway ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayUpdateRequest" + } + } + }, + "description": "Gateway attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayResponse" + } + } + }, + "description": "Gateway Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update a Gateway (rename)", + "tags": [ + "Gateways" + ] + }, + "put": { + "callbacks": {}, + "description": "Renames a Gateway. Gateways otherwise self-register their configuration (name, IP addresses) on first connect and can only be renamed, not fully edited, via this endpoint.\n", + "operationId": "PortalAPI.GatewayController.update", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Gateway ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayUpdateRequest" + } + } + }, + "description": "Gateway attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayResponse" + } + } + }, + "description": "Gateway Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update a Gateway (rename)", + "tags": [ + "Gateways" + ] + } + }, + "/santa_posture_providers": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.SantaPostureProviderController.index", + "parameters": [ + { + "description": "Limit providers returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/previous page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SantaPostureProviderListResponse" + } + } + }, + "description": "Santa posture provider response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Santa posture providers", + "tags": [ + "Santa Posture Providers" + ] + } + }, + "/oidc_auth_providers": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.OIDCAuthProviderController.index", + "parameters": [ + { + "description": "Limit OIDC Auth Providers returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to OIDC Auth Providers with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OIDCAuthProviderListResponse" + } + } + }, + "description": "OIDC Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List OIDC Auth Providers", + "tags": [ + "OIDC Auth Providers" + ] + } + }, + "/sites/{site_id}/gateway_tokens/{id}": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.GatewayTokenController.delete", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Token ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletedGatewayTokenResponse" + } + } + }, + "description": "Deleted Token Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete a Gateway Token", + "tags": [ + "Gateway Tokens" + ] + } + }, + "/sentinelone_posture_providers": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.SentinelOnePostureProviderController.index", + "parameters": [ + { + "description": "Limit providers returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/previous page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SentinelOnePostureProviderListResponse" + } + } + }, + "description": "SentinelOne posture provider response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List SentinelOne posture providers", + "tags": [ + "SentinelOne Posture Providers" + ] + } + }, + "/actors/{id}": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.ActorController.delete", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorResponse" + } + } + }, + "description": "ActorResponse" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete an Actor", + "tags": [ + "Actors" + ] + }, + "get": { + "callbacks": {}, + "operationId": "PortalAPI.ActorController.show", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorResponse" + } + } + }, + "description": "ActorResponse" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Actor", + "tags": [ + "Actors" + ] + }, + "patch": { + "callbacks": {}, + "description": "Updates an Actor.\n\n**Warning: changing an Actor's email signs them out and unlinks their identity providers.**\n\nIf the `email` field is changed to a different address, Firezone will:\n\n- Unlink every identity provider (Google, Okta, Entra, etc.) connected to this Actor.\n- End all active sessions for this Actor, both in the admin portal and on\n connected Client devices. The user will be signed out immediately.\n\nThe Actor will need to sign in again through their identity provider, which\nre-links it under the new email.\n\nEmail comparison ignores case and surrounding whitespace, so changes like\n`User@Example.com` → `user@example.com` are not treated as a real change\nand will not unlink identities.\n\n**Setting `is_disabled` to `true` immediately revokes all of the Actor's\nactive Client tokens and portal sessions.** An Actor cannot disable itself.\n", + "operationId": "PortalAPI.ActorController.update (2)", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorUpdateRequest" + } + } + }, + "description": "Actor attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorResponse" + } + } + }, + "description": "ActorResponse" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update an Actor", + "tags": [ + "Actors" + ] + }, + "put": { + "callbacks": {}, + "description": "Updates an Actor.\n\n**Warning: changing an Actor's email signs them out and unlinks their identity providers.**\n\nIf the `email` field is changed to a different address, Firezone will:\n\n- Unlink every identity provider (Google, Okta, Entra, etc.) connected to this Actor.\n- End all active sessions for this Actor, both in the admin portal and on\n connected Client devices. The user will be signed out immediately.\n\nThe Actor will need to sign in again through their identity provider, which\nre-links it under the new email.\n\nEmail comparison ignores case and surrounding whitespace, so changes like\n`User@Example.com` → `user@example.com` are not treated as a real change\nand will not unlink identities.\n\n**Setting `is_disabled` to `true` immediately revokes all of the Actor's\nactive Client tokens and portal sessions.** An Actor cannot disable itself.\n", + "operationId": "PortalAPI.ActorController.update", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorUpdateRequest" + } + } + }, + "description": "Actor attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorResponse" + } + } + }, + "description": "ActorResponse" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update an Actor", + "tags": [ + "Actors" + ] + } + }, + "/clients/{id}/verify": { + "put": { + "callbacks": {}, + "operationId": "PortalAPI.ClientController.verify", + "parameters": [ + { + "description": "Client ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientResponse" + } + } + }, + "description": "Client Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Verify Client", + "tags": [ + "Clients" + ] + } + }, + "/actors/{actor_id}/client_tokens": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.ClientTokenController.delete_all", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "actor_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletedClientTokensResponse" + } + } + }, + "description": "Deleted Client Tokens Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete all Client Tokens for service_account, account_user, or account_admin_user actors", + "tags": [ + "Client Tokens" + ] + }, + "get": { + "callbacks": {}, + "operationId": "PortalAPI.ClientTokenController.index", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "actor_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Limit Client Tokens returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientTokenListResponse" + } + } + }, + "description": "Client Token List Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Client Tokens for service_account, account_user, or account_admin_user actors", + "tags": [ + "Client Tokens" + ] + }, + "post": { + "callbacks": {}, + "operationId": "PortalAPI.ClientTokenController.create", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "actor_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientTokenRequest" + } + } + }, + "description": "Client Token Attributes", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientTokenCreateResponse" + } + } + }, + "description": "Client Token Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Create a Client Token for a Service Account", + "tags": [ + "Client Tokens" + ] + } + }, + "/actors/{actor_id}/client_tokens/{id}": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.ClientTokenController.delete", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "actor_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Client Token ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletedClientTokenResponse" + } + } + }, + "description": "Deleted Client Token Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete a Client Token", + "tags": [ + "Client Tokens" + ] + }, + "get": { + "callbacks": {}, + "operationId": "PortalAPI.ClientTokenController.show", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "actor_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Client Token ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientTokenShowResponse" + } + } + }, + "description": "Client Token Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show a Client Token for service_account, account_user, or account_admin_user actors", + "tags": [ + "Client Tokens" + ] + } + }, + "/email_otp_auth_providers": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.EmailOTPAuthProviderController.index", + "parameters": [ + { + "description": "Limit Email OTP Auth Providers returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Email OTP Auth Providers with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailOTPAuthProviderListResponse" + } + } + }, + "description": "Email OTP Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Email OTP Auth Providers", + "tags": [ + "Email OTP Auth Providers" + ] + } + }, + "/entra_auth_providers": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.EntraAuthProviderController.index", + "parameters": [ + { + "description": "Limit Entra Auth Providers returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Entra Auth Providers with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntraAuthProviderListResponse" + } + } + }, + "description": "Entra Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Entra Auth Providers", + "tags": [ + "Entra Auth Providers" + ] + } + }, + "/iru_posture_providers/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.IruPostureProviderController.show", + "parameters": [ + { + "description": "Iru posture provider ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IruPostureProviderResponse" + } + } + }, + "description": "Iru posture provider response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show an Iru posture provider", + "tags": [ + "Iru Posture Providers" + ] + } + }, + "/sites": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.SiteController.index", + "parameters": [ + { + "description": "Limit Sites returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to the Site with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteListResponse" + } + } + }, + "description": "Site Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Sites", + "tags": [ + "Sites" + ] + }, + "post": { + "callbacks": {}, + "operationId": "PortalAPI.SiteController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteCreateRequest" + } + } + }, + "description": "Site Attributes", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteResponse" + } + } + }, + "description": "Site Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Create Site", + "tags": [ + "Sites" + ] + } + }, + "/sites/{id}": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.SiteController.delete", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteResponse" + } + } + }, + "description": "Site Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete a Site", + "tags": [ + "Sites" + ] + }, + "get": { + "callbacks": {}, + "operationId": "PortalAPI.SiteController.show", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteResponse" + } + } + }, + "description": "Site Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Site", + "tags": [ + "Sites" + ] + }, + "patch": { + "callbacks": {}, + "operationId": "PortalAPI.SiteController.update (2)", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteUpdateRequest" + } + } + }, + "description": "Site Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteResponse" + } + } + }, + "description": "Site Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update a Site", + "tags": [ + "Sites" + ] + }, + "put": { + "callbacks": {}, + "operationId": "PortalAPI.SiteController.update", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteUpdateRequest" + } + } + }, + "description": "Site Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteResponse" + } + } + }, + "description": "Site Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update a Site", + "tags": [ + "Sites" + ] + } + }, + "/google_auth_providers": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.GoogleAuthProviderController.index", + "parameters": [ + { + "description": "Limit Google Auth Providers returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Google Auth Providers with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoogleAuthProviderListResponse" + } + } + }, + "description": "Google Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Google Auth Providers", + "tags": [ + "Google Auth Providers" + ] + } + }, + "/policies/{id}": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.PolicyController.delete", + "parameters": [ + { + "description": "Policy ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyResponse" + } + } + }, + "description": "Policy Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete a Policy", + "tags": [ + "Policies" + ] + }, + "get": { + "callbacks": {}, + "operationId": "PortalAPI.PolicyController.show", + "parameters": [ + { + "description": "Policy ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyResponse" + } + } + }, + "description": "Policy Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Policy", + "tags": [ + "Policies" + ] + }, + "patch": { + "callbacks": {}, + "description": "Updates a Policy.\n\nA Policy is enabled or disabled through the `is_disabled` field. Disabling a Policy stops it granting access without deleting it.\n", + "operationId": "PortalAPI.PolicyController.update (2)", + "parameters": [ + { + "description": "Policy ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyUpdateRequest" + } + } + }, + "description": "Policy Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyResponse" + } + } + }, + "description": "Policy Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update a Policy", + "tags": [ + "Policies" + ] + }, + "put": { + "callbacks": {}, + "description": "Updates a Policy.\n\nA Policy is enabled or disabled through the `is_disabled` field. Disabling a Policy stops it granting access without deleting it.\n", + "operationId": "PortalAPI.PolicyController.update", + "parameters": [ + { + "description": "Policy ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyUpdateRequest" + } + } + }, + "description": "Policy Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyResponse" + } + } + }, + "description": "Policy Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update a Policy", + "tags": [ + "Policies" + ] + } + }, + "/intune_posture_providers/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.IntunePostureProviderController.show", + "parameters": [ + { + "description": "Intune posture provider ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntunePostureProviderResponse" + } + } + }, + "description": "Intune posture provider response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show an Intune posture provider", + "tags": [ + "Intune Posture Providers" + ] + } + }, + "/x509_auth_provider": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.X509AuthProviderController.show", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/X509AuthProviderResponse" + } + } + }, + "description": "X.509 Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show X.509 Auth Provider", + "tags": [ + "X.509 Auth Providers" + ] + } + }, + "/resources": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.ResourceController.index", + "parameters": [ + { + "description": "Limit Resources returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Resources with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Resources of this type: cidr, ip, dns, or static_device_pool.", + "example": "dns", + "in": "query", + "name": "type", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Resources connected to this Site", + "in": "query", + "name": "site_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Resources with this exact address", + "in": "query", + "name": "address", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Resources with this exact ip_stack", + "example": "dual", + "in": "query", + "name": "ip_stack", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceListResponse" + } + } + }, + "description": "Resource Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Resources", + "tags": [ + "Resources" + ] + }, + "post": { + "callbacks": {}, + "operationId": "PortalAPI.ResourceController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceCreateRequest" + } + } + }, + "description": "Resource Attributes", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceResponse" + } + } + }, + "description": "Resource Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Create Resource", + "tags": [ + "Resources" + ] + } + }, + "/actors/{actor_id}/external_identities/{id}": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.ExternalIdentityController.delete", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "actor_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "External Identity ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalIdentityResponse" + } + } + }, + "description": "ExternalIdentity Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete an External Identity", + "tags": [ + "External Identities" + ] + }, + "get": { + "callbacks": {}, + "operationId": "PortalAPI.ExternalIdentityController.show", + "parameters": [ + { + "description": "Actor ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "actor_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "External Identity ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalIdentityResponse" + } + } + }, + "description": "ExternalIdentity Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show External Identity", + "tags": [ + "External Identities" + ] + } + }, + "/sentinelone_devices": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.SentinelOneDeviceController.index", + "parameters": [ + { + "description": "Limit devices returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/previous page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SentinelOneDeviceListResponse" + } + } + }, + "description": "SentinelOne device response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List synced SentinelOne devices", + "tags": [ + "SentinelOne Devices" + ] + } + }, + "/okta_auth_providers": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.OktaAuthProviderController.index", + "parameters": [ + { + "description": "Limit Okta Auth Providers returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Okta Auth Providers with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OktaAuthProviderListResponse" + } + } + }, + "description": "Okta Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Okta Auth Providers", + "tags": [ + "Okta Auth Providers" + ] + } + }, + "/defender_devices/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.DefenderDeviceController.show", + "parameters": [ + { + "description": "Synced Defender device ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefenderDeviceResponse" + } + } + }, + "description": "Defender device response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show a synced Defender device", + "tags": [ + "Defender Devices" + ] + } + }, + "/intune_devices/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.IntuneDeviceController.show", + "parameters": [ + { + "description": "Synced Intune device ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntuneDeviceResponse" + } + } + }, + "description": "Intune device response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show a synced Intune device", + "tags": [ + "Intune Devices" + ] + } + }, + "/defender_posture_providers/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.DefenderPostureProviderController.show", + "parameters": [ + { + "description": "Defender posture provider ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefenderPostureProviderResponse" + } + } + }, + "description": "Defender posture provider response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show a Defender posture provider", + "tags": [ + "Defender Posture Providers" + ] + } + }, + "/intune_posture_providers": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.IntunePostureProviderController.index", + "parameters": [ + { + "description": "Limit providers returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/previous page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntunePostureProviderListResponse" + } + } + }, + "description": "Intune posture provider response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Intune posture providers", + "tags": [ + "Intune Posture Providers" + ] + } + }, + "/groups/{group_id}/memberships": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.MembershipController.index", + "parameters": [ + { + "description": "ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Limit Memberships returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipListResponse" + } + } + }, + "description": "Membership Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Memberships", + "tags": [ + "Memberships" + ] + }, + "patch": { + "callbacks": {}, + "description": "Adds and/or removes individual Actors, leaving every other member of the\nGroup untouched.\n\nBoth operations are idempotent: adding an Actor already in the Group and\nremoving one that isn't are both no-ops. `remove` is applied before `add`,\nso an Actor named in both ends up in the Group. Retrying a request that may\nalready have been applied is therefore safe.\n\nRepeating an Actor within `add` or `remove` is not an error; both lists are\ndeduplicated. `actor_ids` in the response is sorted.\n", + "operationId": "PortalAPI.MembershipController.update_patch", + "parameters": [ + { + "description": "ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipPatchRequest" + } + } + }, + "description": "Membership Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipResponse" + } + } + }, + "description": "Membership Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update an Membership", + "tags": [ + "Memberships" + ] + }, + "put": { + "callbacks": {}, + "description": "Replaces the Group's entire membership list with the given Actors.\n\nAny Actor not named in the request is removed from the Group. To add or\nremove individual Actors without disturbing the rest, use `PATCH`.\n\nMembers that are not changing keep their membership rows, so a replace that\nleaves the list untouched is a no-op rather than a full rewrite.\n\nRepeating an Actor in the request body is not an error; the list is\ndeduplicated. `actor_ids` in the response is sorted, not returned in\nrequest order.\n", + "operationId": "PortalAPI.MembershipController.update_put", + "parameters": [ + { + "description": "ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "group_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipPutRequest" + } + } + }, + "description": "Membership Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MembershipResponse" + } + } + }, + "description": "Membership Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update Memberships", + "tags": [ + "Memberships" + ] + } + }, + "/google_directories": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.GoogleDirectoryController.index", + "parameters": [ + { + "description": "Limit Google Directories returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Google Directories with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoogleDirectoryListResponse" + } + } + }, + "description": "Google Directory Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Google Directories", + "tags": [ + "Google Directories" + ] + } + }, + "/sentinelone_devices/{sentinelone_agent}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.SentinelOneDeviceController.show", + "parameters": [ + { + "description": "Synced SentinelOne agent UUID", + "in": "path", + "name": "sentinelone_agent", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SentinelOneDeviceResponse" + } + } + }, + "description": "SentinelOne device response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show a synced SentinelOne device", + "tags": [ + "SentinelOne Devices" + ] + } + }, + "/santa_posture_providers/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.SantaPostureProviderController.show", + "parameters": [ + { + "description": "Santa posture provider ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SantaPostureProviderResponse" + } + } + }, + "description": "Santa posture provider response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show a Santa posture provider", + "tags": [ + "Santa Posture Providers" + ] + } + }, + "/google_directories/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.GoogleDirectoryController.show", + "parameters": [ + { + "description": "Google Directory ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoogleDirectoryResponse" + } + } + }, + "description": "Google Directory Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Google Directory", + "tags": [ + "Google Directories" + ] + } + }, + "/santa_devices": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.SantaDeviceController.index", + "parameters": [ + { + "description": "Limit devices returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/previous page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SantaDeviceListResponse" + } + } + }, + "description": "Santa device response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List synced Santa devices", + "tags": [ + "Santa Devices" + ] + } + }, + "/clients/{id}/unverify": { + "put": { + "callbacks": {}, + "operationId": "PortalAPI.ClientController.unverify", + "parameters": [ + { + "description": "Client ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientResponse" + } + } + }, + "description": "Client Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Unverify Client", + "tags": [ + "Clients" + ] + } + }, + "/resources/{id}": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.ResourceController.delete", + "parameters": [ + { + "description": "Resource ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceResponse" + } + } + }, + "description": "Resource Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete Resource", + "tags": [ + "Resources" + ] + }, + "get": { + "callbacks": {}, + "operationId": "PortalAPI.ResourceController.show", + "parameters": [ + { + "description": "Resource ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceResponse" + } + } + }, + "description": "Resource Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Resource", + "tags": [ + "Resources" + ] + }, + "patch": { + "callbacks": {}, + "operationId": "PortalAPI.ResourceController.update (2)", + "parameters": [ + { + "description": "Resource ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceUpdateRequest" + } + } + }, + "description": "Resource Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceResponse" + } + } + }, + "description": "Resource Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update Resource", + "tags": [ + "Resources" + ] + }, + "put": { + "callbacks": {}, + "operationId": "PortalAPI.ResourceController.update", + "parameters": [ + { + "description": "Resource ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceUpdateRequest" + } + } + }, + "description": "Resource Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceResponse" + } + } + }, + "description": "Resource Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update Resource", + "tags": [ + "Resources" + ] + } + }, + "/entra_directories": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.EntraDirectoryController.index", + "parameters": [ + { + "description": "Limit Entra Directories returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Entra Directories with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntraDirectoryListResponse" + } + } + }, + "description": "Entra Directory Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Entra Directories", + "tags": [ + "Entra Directories" + ] + } + }, + "/entra_auth_providers/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.EntraAuthProviderController.show", + "parameters": [ + { + "description": "Entra Auth Provider ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntraAuthProviderResponse" + } + } + }, + "description": "Entra Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Entra Auth Provider", + "tags": [ + "Entra Auth Providers" + ] + } + }, + "/clients": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.ClientController.index", + "parameters": [ + { + "description": "Limit Clients returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Clients with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to the Client with this exact Firezone ID", + "in": "query", + "name": "firezone_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientsResponse" + } + } + }, + "description": "Client Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Clients", + "tags": [ + "Clients" + ] + } + }, + "/account": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.AccountController.show", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountResponse" + } + } + }, + "description": "AccountResponse" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Account", + "tags": [ + "Account" + ] + } + }, + "/sites/{site_id}/gateway_tokens": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.GatewayTokenController.delete_all", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletedGatewayTokensResponse" + } + } + }, + "description": "Deleted Tokens Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete all Gateway Tokens for a Site", + "tags": [ + "Gateway Tokens" + ] + }, + "post": { + "callbacks": {}, + "deprecated": true, + "description": "Deprecated: creates a multi-owner Site token shared by all of a Site's gateways. Prefer creating a single-owner token for a specific gateway via `POST /sites/{site_id}/gateways/{gateway_id}/token`.", + "operationId": "PortalAPI.GatewayTokenController.create", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayTokenResponse" + } + } + }, + "description": "New Token Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Create a Gateway Token", + "tags": [ + "Gateway Tokens" + ] + } + }, + "/iru_devices/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.IruDeviceController.show", + "parameters": [ + { + "description": "Synced Iru device ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IruDeviceResponse" + } + } + }, + "description": "Iru device response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show a synced Iru device", + "tags": [ + "Iru Devices" + ] + } + }, + "/intune_devices": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.IntuneDeviceController.index", + "parameters": [ + { + "description": "Limit devices returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/previous page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IntuneDeviceListResponse" + } + } + }, + "description": "Intune device response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List synced Intune devices", + "tags": [ + "Intune Devices" + ] + } + }, + "/resources/{resource_id}/pool_members": { + "get": { + "callbacks": {}, + "description": "Lists the Clients belonging to a `static_device_pool` Resource.\n\nReturns 400 for any other Resource type - only device pools have members.\n", + "operationId": "PortalAPI.PoolMemberController.index", + "parameters": [ + { + "description": "Resource ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Limit Pool Members returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMemberListResponse" + } + } + }, + "description": "Pool Member Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Pool Members", + "tags": [ + "Pool Members" + ] + }, + "patch": { + "callbacks": {}, + "description": "Adds and/or removes individual Clients, leaving every other member of the\npool untouched.\n\nBoth operations are idempotent: adding a Client already in the pool and\nremoving one that isn't are both no-ops. `remove` is applied before `add`,\nso a Client named in both ends up in the pool.\n", + "operationId": "PortalAPI.PoolMemberController.update_patch", + "parameters": [ + { + "description": "Resource ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMemberPatchRequest" + } + } + }, + "description": "Pool Member Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMemberResponse" + } + } + }, + "description": "Pool Member Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Add or Remove Pool Members", + "tags": [ + "Pool Members" + ] + }, + "put": { + "callbacks": {}, + "description": "Replaces the Resource's entire membership list with the given Clients.\n\nAny Client not named in the request is removed from the pool. To add or\nremove individual Clients without disturbing the rest, use `PATCH`.\n", + "operationId": "PortalAPI.PoolMemberController.update_put", + "parameters": [ + { + "description": "Resource ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "resource_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMemberPutRequest" + } + } + }, + "description": "Pool Member Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PoolMemberResponse" + } + } + }, + "description": "Pool Member Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Replace Pool Members", + "tags": [ + "Pool Members" + ] + } + }, + "/entra_directories/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.EntraDirectoryController.show", + "parameters": [ + { + "description": "Entra Directory ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntraDirectoryResponse" + } + } + }, + "description": "Entra Directory Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Entra Directory", + "tags": [ + "Entra Directories" + ] + } + }, + "/sentinelone_posture_providers/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.SentinelOnePostureProviderController.show", + "parameters": [ + { + "description": "SentinelOne posture provider ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SentinelOnePostureProviderResponse" + } + } + }, + "description": "SentinelOne posture provider response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show a SentinelOne posture provider", + "tags": [ + "SentinelOne Posture Providers" + ] + } + }, + "/email_otp_auth_providers/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.EmailOTPAuthProviderController.show", + "parameters": [ + { + "description": "Email OTP Auth Provider ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailOTPAuthProviderResponse" + } + } + }, + "description": "Email OTP Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Email OTP Auth Provider", + "tags": [ + "Email OTP Auth Providers" + ] + } + }, + "/okta_directories/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.OktaDirectoryController.show", + "parameters": [ + { + "description": "Okta Directory ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OktaDirectoryResponse" + } + } + }, + "description": "Okta Directory Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Okta Directory", + "tags": [ + "Okta Directories" + ] + } + }, + "/iru_devices": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.IruDeviceController.index", + "parameters": [ + { + "description": "Limit devices returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/previous page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IruDeviceListResponse" + } + } + }, + "description": "Iru device response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List synced Iru devices", + "tags": [ + "Iru Devices" + ] + } + }, + "/groups/{id}": { + "delete": { + "callbacks": {}, + "operationId": "PortalAPI.GroupController.delete", + "parameters": [ + { + "description": "Group ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupResponse" + } + } + }, + "description": "Group Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Delete a Group", + "tags": [ + "Groups" + ] + }, + "get": { + "callbacks": {}, + "operationId": "PortalAPI.GroupController.show", + "parameters": [ + { + "description": "Group ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupResponse" + } + } + }, + "description": "Group Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Group", + "tags": [ + "Groups" + ] + }, + "patch": { + "callbacks": {}, + "operationId": "PortalAPI.GroupController.update (2)", + "parameters": [ + { + "description": "Group ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupUpdateRequest" + } + } + }, + "description": "Group Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupResponse" + } + } + }, + "description": "Group Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update a Group", + "tags": [ + "Groups" + ] + }, + "put": { + "callbacks": {}, + "operationId": "PortalAPI.GroupController.update", + "parameters": [ + { + "description": "Group ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupUpdateRequest" + } + } + }, + "description": "Group Attributes", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupResponse" + } + } + }, + "description": "Group Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Update a Group", + "tags": [ + "Groups" + ] + } + }, + "/logs/{log_id}": { + "get": { + "callbacks": {}, + "description": "Fetches a single Log entry by its `log_id`. The entry's type is\ndetermined from the `log_id` itself: its first character identifies\nthe log stream (`c` change, `5` session, `f` flow, `a` api_request).\n", + "operationId": "PortalAPI.LogController.show", + "parameters": [ + { + "description": "Identifier of the Log entry. A 24-character lowercase hexadecimal string.", + "example": "c00060db0c2c8eb400000000", + "in": "path", + "name": "log_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogResponse" + } + } + }, + "description": "Log Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Log", + "tags": [ + "Logs" + ] + } + }, + "/oidc_auth_providers/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.OIDCAuthProviderController.show", + "parameters": [ + { + "description": "OIDC Auth Provider ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OIDCAuthProviderResponse" + } + } + }, + "description": "OIDC Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show OIDC Auth Provider", + "tags": [ + "OIDC Auth Providers" + ] + } + }, + "/sites/{site_id}/gateways": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.GatewayController.index", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Limit Gateways returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to the Gateway with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to the Gateway with this exact tunnel IPv4 address", + "in": "query", + "name": "ipv4", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to the Gateway with this exact tunnel IPv6 address", + "in": "query", + "name": "ipv6", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewaysResponse" + } + } + }, + "description": "Gateway Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Gateways", + "tags": [ + "Gateways" + ] + }, + "post": { + "callbacks": {}, + "description": "Creates a Gateway and mints its single-owner token in one call. The token is returned once here - store it securely. If it's lost, rotate it via `POST /sites/{site_id}/gateways/{gateway_id}/token/rotate` once the Gateway has connected, or delete and re-provision it.\n\n`name` is optional; a random name is generated when omitted. `ipv4` and `ipv6` are the Gateway's tunnel addresses, allocated from the account's pool when the Gateway row is created - they are always present in this response, before the Gateway has ever connected.\n", + "operationId": "PortalAPI.GatewayController.create", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayCreateRequest" + } + } + }, + "description": "Gateway attributes", + "required": false + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayProvisionResponse" + } + } + }, + "description": "Provisioned Gateway Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Provision a Gateway", + "tags": [ + "Gateways" + ] + } + }, + "/defender_devices": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.DefenderDeviceController.index", + "parameters": [ + { + "description": "Limit devices returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/previous page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefenderDeviceListResponse" + } + } + }, + "description": "Defender device response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List synced Defender devices", + "tags": [ + "Defender Devices" + ] + } + }, + "/policies": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.PolicyController.index", + "parameters": [ + { + "description": "Limit Policies returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Policies granting this Group", + "in": "query", + "name": "group_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Policies granting access to this Resource", + "in": "query", + "name": "resource_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyListResponse" + } + } + }, + "description": "Policy Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Policies", + "tags": [ + "Policies" + ] + }, + "post": { + "callbacks": {}, + "operationId": "PortalAPI.PolicyController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyCreateRequest" + } + } + }, + "description": "Policy Attributes", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PolicyResponse" + } + } + }, + "description": "Policy Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Create Policy", + "tags": [ + "Policies" + ] + } + }, + "/logs": { + "get": { + "callbacks": {}, + "description": "Lists log entries of the requested `type` for the authenticated account.\n\n- `change`: audit entries recording each insert, update, or delete event\n against an account-scoped object, most recent first.\n- `session`: one entry per Client, Gateway, or Portal session created,\n most recent first.\n- `flow`: one entry per network flow reported by Clients and Gateways,\n most recently started first.\n- `api_request`: one entry per authenticated REST API request, most\n recent first.\n\nThe `begin` and `end` query parameters bound the time window. For\n`change`, `session`, and `api_request`, entries match when their\n`timestamp` falls inside the window; for `flow`, entries match when\nthe flow was active at any point inside the window, i.e. when\n`[flow_start, flow_end)` overlaps it. Both must be RFC 3339 (ISO\n8601) timestamps, for example `2026-05-26T00:00:00Z`; values with a\nnon-UTC offset are accepted and converted to UTC. When omitted,\n`begin` defaults to 90 days before the current time and `end`\ndefaults to the current time. `begin` must be less than or equal to\n`end`.\n\nResults can be further narrowed by `actor_id` (every type) or\n`actor_email` (`change`, `session`, and `flow`), which matches the\nemail recorded when the entry was created. API requests are made by\nAPI clients, which have no email, so `actor_email` is not supported\nfor `api_request`.\n\nUse the `next_page` cursor returned in `metadata` to fetch the\nfollowing page of results.\n", + "operationId": "PortalAPI.LogController.index", + "parameters": [ + { + "description": "The log stream to list. One of `change`, `session`, `flow`, or `api_request`.", + "example": "change", + "in": "query", + "name": "type", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of Logs to return per page. Defaults to 50.\nValues greater than 100 are capped to 100, and values less than 1\nare raised to 1.\n", + "example": 50, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor returned by a previous request.", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive start of the time window. RFC 3339 timestamp; non-UTC\noffsets are accepted and converted to UTC. Defaults to 90 days\nbefore the current time when omitted.\n\nFor `flow` entries the window applies to when the flow started.\n", + "example": "2026-02-25T00:00:00Z", + "in": "query", + "name": "begin", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Inclusive end of the time window. RFC 3339 timestamp; non-UTC\noffsets are accepted and converted to UTC. Defaults to the current\ntime when omitted.\n\nFor `flow` entries the window applies to when the flow started.\n", + "example": "2026-05-26T00:00:00Z", + "in": "query", + "name": "end", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to entries whose actor matches.", + "example": "84e7f82f-831a-4a9d-8f17-c66c2bb6e205", + "in": "query", + "name": "actor_id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to entries whose actor email matches the email recorded\nwhen the entry was created. Supported for `change`, `session`, and\n`flow` types.\n", + "example": "admin@example.com", + "in": "query", + "name": "actor_email", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogsResponse" + } + } + }, + "description": "Logs Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Logs", + "tags": [ + "Logs" + ] + } + }, + "/actors": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.ActorController.index", + "parameters": [ + { + "description": "Limit Users returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Actors with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Actors with this exact email", + "in": "query", + "name": "email", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Actors of this type: account_user, account_admin_user, service_account, or api_client.", + "example": "service_account", + "in": "query", + "name": "type", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorsResponse" + } + } + }, + "description": "ActorsResponse" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Actors", + "tags": [ + "Actors" + ] + }, + "post": { + "callbacks": {}, + "operationId": "PortalAPI.ActorController.create", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorCreateRequest" + } + } + }, + "description": "Actor attributes", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActorResponse" + } + } + }, + "description": "ActorResponse" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "422": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request body failed validation.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank", + "validation_errors": { + "name": [ + "can't be blank" + ] + } + }, + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + }, + "description": "Unprocessable Content" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Create an Actor", + "tags": [ + "Actors" + ] + } + }, + "/okta_directories": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.OktaDirectoryController.index", + "parameters": [ + { + "description": "Limit Okta Directories returned", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/Prev page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Filter to Okta Directories with this exact name", + "in": "query", + "name": "name", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OktaDirectoryListResponse" + } + } + }, + "description": "Okta Directory Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Okta Directories", + "tags": [ + "Okta Directories" + ] + } + }, + "/santa_devices/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.SantaDeviceController.show", + "parameters": [ + { + "description": "Synced Santa device ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SantaDeviceResponse" + } + } + }, + "description": "Santa device response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show a synced Santa device", + "tags": [ + "Santa Devices" + ] + } + }, + "/iru_posture_providers": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.IruPostureProviderController.index", + "parameters": [ + { + "description": "Limit providers returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/previous page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IruPostureProviderListResponse" + } + } + }, + "description": "Iru posture provider response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Iru posture providers", + "tags": [ + "Iru Posture Providers" + ] + } + }, + "/okta_auth_providers/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.OktaAuthProviderController.show", + "parameters": [ + { + "description": "Okta Auth Provider ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OktaAuthProviderResponse" + } + } + }, + "description": "Okta Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Okta Auth Provider", + "tags": [ + "Okta Auth Providers" + ] + } + }, + "/sites/{site_id}/gateways/{gateway_id}/token/rotate": { + "post": { + "callbacks": {}, + "description": "Mints a replacement token for the gateway. A current token the gateway has connected with keeps working until the gateway first connects with the replacement or 4 hours elapse, whichever comes first; a token no gateway has ever connected with is replaced immediately. Rotating again before the gateway picks up the replacement replaces only the pending token and never invalidates the one in use. Once the replacement is confirmed the previous token is deleted - rolling the gateway's configuration back to it will strand the gateway.", + "operationId": "PortalAPI.GatewayTokenController.rotate", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Gateway ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "gateway_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayTokenResponse" + } + } + }, + "description": "New Token Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Rotate a single-owner Gateway Token", + "tags": [ + "Gateway Tokens" + ] + } + }, + "/sites/{site_id}/gateways/{gateway_id}/token": { + "post": { + "callbacks": {}, + "description": "Creates a token bound to a single gateway. At most one active token can exist per gateway; if one already exists, this returns 409 Conflict - rotate the token or delete it first instead.", + "operationId": "PortalAPI.GatewayTokenController.create_for_gateway", + "parameters": [ + { + "description": "Site ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "site_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Gateway ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "gateway_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayTokenResponse" + } + } + }, + "description": "New Token Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request conflicts with the current state of the resource.", + "status": 409, + "title": "Conflict", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Conflict" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Create a single-owner Gateway Token", + "tags": [ + "Gateway Tokens" + ] + } + }, + "/defender_posture_providers": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.DefenderPostureProviderController.index", + "parameters": [ + { + "description": "Limit providers returned", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Next/previous page cursor", + "in": "query", + "name": "page_cursor", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefenderPostureProviderListResponse" + } + } + }, + "description": "Defender posture provider response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "List Defender posture providers", + "tags": [ + "Defender Posture Providers" + ] + } + }, + "/google_auth_providers/{id}": { + "get": { + "callbacks": {}, + "operationId": "PortalAPI.GoogleAuthProviderController.show", + "parameters": [ + { + "description": "Google Auth Provider ID", + "example": "00000000-0000-0000-0000-000000000000", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoogleAuthProviderResponse" + } + } + }, + "description": "Google Auth Provider Response" + }, + "400": { + "content": { + "application/problem+json": { + "example": { + "detail": "The request could not be processed.", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/problem+json": { + "example": { + "detail": "Authentication credentials were missing or invalid.", + "status": 401, + "title": "Unauthorized", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/problem+json": { + "example": { + "detail": "You do not have permission to perform this action.", + "status": 403, + "title": "Forbidden", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/problem+json": { + "example": { + "detail": "The requested resource could not be found.", + "status": 404, + "title": "Not Found", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Not Found" + }, + "429": { + "content": { + "application/problem+json": { + "example": { + "detail": "Rate limit exceeded. Retry after the time indicated in the Retry-After header.", + "status": 429, + "title": "Too Many Requests", + "type": "about:blank" + }, + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Too Many Requests" + } + }, + "summary": "Show Google Auth Provider", + "tags": [ + "Google Auth Providers" + ] + } + } + }, + "security": [ + { + "authorization": [] + } + ], + "servers": [ + { + "url": "/", + "variables": {} + } + ], + "tags": [] +} diff --git a/user_agent_test.go b/user_agent_test.go new file mode 100644 index 0000000..dc0e05b --- /dev/null +++ b/user_agent_test.go @@ -0,0 +1,60 @@ +package firezone_test + +import ( + "context" + "net/http" + "regexp" + "runtime" + "strings" + "testing" + + firezone "github.com/firezone/firezone-go" + "github.com/firezone/firezone-go/internal/testutil" +) + +// newRecordingClient returns a client whose stub server records the +// User-Agent of whatever request reaches it. +func newRecordingClient(t *testing.T, ua *string, opts ...firezone.Option) *firezone.Client { + t.Helper() + return testutil.NewClientWithOptions(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *ua = r.Header.Get("User-Agent") + testutil.JSONResponse(http.StatusOK, map[string]any{"data": map[string]any{"id": "site-1"}})(w, r) + }), opts...) +} + +// TestVersion pins the shape of Version. A version that isn't semver +// would break a consumer parsing it, and it ends up on the wire. +func TestVersion(t *testing.T) { + if !regexp.MustCompile(`^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$`).MatchString(firezone.Version) { + t.Errorf("Version = %q, want a semantic version like 1.2.3", firezone.Version) + } +} + +func TestDefaultUserAgent(t *testing.T) { + var ua string + client := newRecordingClient(t, &ua) + if _, err := client.Sites.Get(context.Background(), "site-1"); err != nil { + t.Fatalf("Get returned error: %v", err) + } + + if !strings.HasPrefix(ua, "firezone-go-client/"+firezone.Version) { + t.Errorf("User-Agent = %q, want it to start with firezone-go-client/%s", ua, firezone.Version) + } + if !strings.Contains(ua, runtime.Version()) { + t.Errorf("User-Agent = %q, want it to name the Go runtime (%s)", ua, runtime.Version()) + } +} + +func TestWithUserAgentReplacesTheDefault(t *testing.T) { + const custom = "terraform-provider-firezone/2.1.0" + + var ua string + client := newRecordingClient(t, &ua, firezone.WithUserAgent(custom)) + if _, err := client.Sites.Get(context.Background(), "site-1"); err != nil { + t.Fatalf("Get returned error: %v", err) + } + + if ua != custom { + t.Errorf("User-Agent = %q, want exactly %q - WithUserAgent replaces rather than extends", ua, custom) + } +}