diff --git a/README.md b/README.md index 14623d3..274ae2e 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ store. The seed script creates a fresh throwaway account and an 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 +create — Client devices — 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) @@ -277,3 +277,24 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). To report a security issue, see Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE) for details. + + +## Device pools + +Create pools with `ResourceTypeDevicePool` and `DeviceMembershipCriteria`. +The criteria use the API's JSON shape, carried as `json.RawMessage`: + +```go +pool, err := client.Resources.Create(ctx, &firezone.CreateResourceRequest{ + Name: "My Devices", + Type: firezone.ResourceTypeDevicePool, + DeviceMembershipCriteria: json.RawMessage(`{"device":{"field":"actor_id","op":"eq","value":{"subject":"actor_id"}}}`), +}) +``` + +Other rules select all account devices (`account_id` compared to the subject's +`account_id`), listed devices (`device` / `id` / `in` / an array of Client UUIDs), +or an actor group's devices (`actor_group` / `id` / `eq` / a Group UUID). +Device pools have no Site or address. Only listed criteria support `PoolMembers`. +A nil criteria field on update preserves membership; a non-nil value replaces +the whole rule. The legacy `ResourceTypeStaticDevicePool` constant is deprecated. diff --git a/integration_test.go b/integration_test.go index cb2bef4..4458116 100644 --- a/integration_test.go +++ b/integration_test.go @@ -946,43 +946,45 @@ func TestIntegration_Memberships(t *testing.T) { } } -// 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. +// TestIntegration_PoolMembers owns its pool, so membership operations do not +// alter an existing pool in the test account. func TestIntegration_PoolMembers(t *testing.T) { c := integrationClient(t) - - pools, err := c.Resources.List(ctx(), &firezone.ResourceListOptions{ - Type: firezone.ResourceTypeStaticDevicePool, + pool, err := c.Resources.Create(ctx(), &firezone.CreateResourceRequest{ + Name: "sdk-integration-pool", Type: firezone.ResourceTypeDevicePool, + DeviceMembershipCriteria: []byte(`{"device":{"field":"id","op":"in","value":[]}}`), }) if err != nil { - t.Fatalf("listing static device pools: %v", err) + t.Fatal(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") + t.Cleanup(func() { + if err := c.Resources.Delete(ctx(), pool.ID); err != nil { + t.Error(err) + } + }) + members := c.Resources.PoolMembers(pool.ID) + page, err := members.List(ctx(), nil) + if err != nil { + t.Fatal(err) } - - pool := pools.Data[0] - page, err := c.Resources.PoolMembers(pool.ID).List(ctx(), nil) + if len(page.Data) != 0 { + t.Fatalf("new pool has members: %+v", page.Data) + } + if _, err := members.ReplaceAll(ctx(), []string{}); err != nil { + t.Fatal(err) + } + updated, err := c.Resources.Update(ctx(), pool.ID, &firezone.UpdateResourceRequest{ + DeviceMembershipCriteria: []byte(`{"device":{"field":"actor_id","op":"eq","value":{"subject":"actor_id"}}}`), + }) if err != nil { - t.Fatalf("PoolMembers.List for pool %s: %v", pool.ID, err) + t.Fatal(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) + if len(updated.DeviceMembershipCriteria) == 0 { + t.Fatal("missing criteria in update response") + } + if _, err := members.List(ctx(), nil); err == nil { + t.Fatal("dynamic pool unexpectedly supports pool_members") } - - // 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 diff --git a/pool_members.go b/pool_members.go index 9974f6d..2d814bc 100644 --- a/pool_members.go +++ b/pool_members.go @@ -8,7 +8,7 @@ import ( // PoolMember is a Client's minimal representation as returned by // [PoolMembersService.List]. // -// Pool members are Client devices, not Actors - a static device pool +// Pool members are Client devices, not Actors - a listed device pool // grants access to specific machines, so this is not the device-shaped // equivalent of [GroupMember] despite the similar surface. type PoolMember struct { @@ -18,10 +18,10 @@ type PoolMember struct { } // PoolMembersService manages the membership of a single -// static_device_pool Resource. Obtain one via +// device_pool Resource. Obtain one via // [ResourcesService.PoolMembers]. // -// Every method returns 400 if the Resource is not a static_device_pool; +// Every method returns 400 if the Resource is not a device_pool with listed-device criteria; // no other Resource type has members. type PoolMembersService struct { client *Client diff --git a/resources.go b/resources.go index 5205314..f216737 100644 --- a/resources.go +++ b/resources.go @@ -1,6 +1,9 @@ package firezone -import "context" +import ( + "context" + "encoding/json" +) // ResourceType is the type of network object a Resource represents. type ResourceType string @@ -13,16 +16,12 @@ const ( ResourceTypeIP ResourceType = "ip" ResourceTypeDNS ResourceType = "dns" - // ResourceTypeStaticDevicePool is currently readable but not - // creatable: the API rejects any request that changes a Resource's - // type to it, on both create and update, with a 422. Create device - // pools in the admin portal instead. - // - // The constant stays because existing pools are still returned by - // Get and List, still filterable via [ResourceListOptions.Type], and - // still updatable and deletable - only the transition into this type - // is refused. Note that restating an existing pool's own type on an - // update is not a transition and is accepted. + // ResourceTypeDevicePool selects Clients using DeviceMembershipCriteria. + ResourceTypeDevicePool ResourceType = "device_pool" + + // ResourceTypeStaticDevicePool is the legacy type used before device pools + // gained membership criteria. + // Deprecated: use ResourceTypeDevicePool with DeviceMembershipCriteria. ResourceTypeStaticDevicePool ResourceType = "static_device_pool" ) @@ -55,27 +54,34 @@ type Filter struct { } // Resource is a Firezone Resource - a network object (CIDR, IP, DNS -// name, or static device pool) that Policies grant access to. +// name, or device pool) that Policies grant access to. type Resource struct { - ID string `json:"id"` - Name string `json:"name"` - Address string `json:"address"` - AddressDescription string `json:"address_description"` - Type ResourceType `json:"type"` - IPStack IPStack `json:"ip_stack,omitempty"` - SiteID string `json:"site_id,omitempty"` - Filters []Filter `json:"filters"` + // DeviceMembershipCriteria is the API's criteria object for a device pool. + // It is absent or null for other Resource types. + DeviceMembershipCriteria json.RawMessage `json:"device_membership_criteria,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + AddressDescription string `json:"address_description"` + Type ResourceType `json:"type"` + IPStack IPStack `json:"ip_stack,omitempty"` + SiteID string `json:"site_id,omitempty"` + Filters []Filter `json:"filters"` } // CreateResourceRequest is the request body for [ResourcesService.Create]. type CreateResourceRequest struct { - Name string `json:"name"` - Type ResourceType `json:"type"` - 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"` + // DeviceMembershipCriteria is required for device_pool Resources. Accepted + // rules select listed Client IDs, the subject's own devices, all account + // devices, or an actor group's devices. See the API's Resource schema. + DeviceMembershipCriteria json.RawMessage `json:"device_membership_criteria,omitempty"` + Name string `json:"name"` + Type ResourceType `json:"type"` + 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"` } // UpdateResourceRequest is the request body for [ResourcesService.Update]. @@ -86,9 +92,12 @@ type CreateResourceRequest struct { // 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 *Null[string] `json:"address,omitempty"` + // DeviceMembershipCriteria replaces the whole criteria object. nil omits + // the field, preserving existing criteria (including pool member edits). + DeviceMembershipCriteria json.RawMessage `json:"device_membership_criteria,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 @@ -104,16 +113,14 @@ type UpdateResourceRequest struct { Filters *[]Filter `json:"filters,omitempty"` } -// ResourcesService manages Resources, and, nested under them, static +// ResourcesService manages Resources, and, nested under them, listed // device pool membership. type ResourcesService struct { client *Client } -// PoolMembers returns a [PoolMembersService] scoped to the -// static_device_pool Resource identified by resourceID. Calling it for -// any other Resource type is allowed, but every request that service -// makes will fail with 400. +// PoolMembers returns a service for a device_pool Resource with listed-device +// criteria. Dynamic criteria do not support the pool_members endpoints. func (s *ResourcesService) PoolMembers(resourceID string) *PoolMembersService { return &PoolMembersService{client: s.client, resourceID: resourceID} } @@ -162,11 +169,7 @@ func (s *ResourcesService) List(ctx context.Context, opts *ResourceListOptions) return doList[Resource](ctx, s.client, "GET", "resources", q) } -// Create creates a new Resource. -// -// Two types cannot be created: "internet" (403 Forbidden) and -// [ResourceTypeStaticDevicePool] (422) - create device pools in the -// admin portal instead. +// Create creates a new Resource. Device pools require membership criteria. func (s *ResourcesService) Create(ctx context.Context, req *CreateResourceRequest) (*Resource, error) { body, err := wrapBody("resource", req) if err != nil { @@ -179,12 +182,7 @@ func (s *ResourcesService) Create(ctx context.Context, req *CreateResourceReques return &resource, nil } -// Update updates a Resource. -// -// Changing a Resource's type to [ResourceTypeStaticDevicePool] is -// refused with a 422, the same as creating one. Restating an existing -// pool's own type is not a change and is accepted, so a caller that -// echoes the whole Resource back on update still works. +// Update updates a Resource, including its type and membership criteria. func (s *ResourcesService) Update(ctx context.Context, id string, req *UpdateResourceRequest) (*Resource, error) { if err := checkID("Resource ID", id); err != nil { return nil, err diff --git a/resources_test.go b/resources_test.go index d972242..5d84abc 100644 --- a/resources_test.go +++ b/resources_test.go @@ -2,7 +2,9 @@ package firezone_test import ( "context" + "encoding/json" "net/http" + "reflect" "testing" firezone "github.com/firezone/firezone-sdk-go" @@ -151,37 +153,92 @@ func TestResourcesService_Create_DevicePoolRejected(t *testing.T) { _, err := client.Resources.Create(context.Background(), &firezone.CreateResourceRequest{ Name: "field-laptops", - Type: firezone.ResourceTypeStaticDevicePool, + Type: firezone.ResourceTypeDevicePool, }) if !firezone.IsValidation(err) { t.Fatalf("IsValidation(err) = false, want true (err: %v)", err) } } -// TestResourcesService_List_FilterByDevicePool guards the reason the -// constant still exists: existing pools remain readable and filterable -// even though they can't be created. +// TestResourcesService_List_FilterByDevicePool checks the current pool type filter. func TestResourcesService_List_FilterByDevicePool(t *testing.T) { var gotQuery string client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotQuery = r.URL.RawQuery testutil.JSONResponse(http.StatusOK, map[string]any{ "data": []map[string]any{ - {"id": "res-1", "name": "field-laptops", "type": "static_device_pool"}, + {"id": "res-1", "name": "field-laptops", "type": "device_pool"}, }, "metadata": map[string]any{"count": 1, "limit": 50}, })(w, r) })) page, err := client.Resources.List(context.Background(), - &firezone.ResourceListOptions{Type: firezone.ResourceTypeStaticDevicePool}) + &firezone.ResourceListOptions{Type: firezone.ResourceTypeDevicePool}) if err != nil { t.Fatalf("List returned error: %v", err) } - if gotQuery != "type=static_device_pool" { - t.Errorf("query = %q, want type=static_device_pool", gotQuery) + if gotQuery != "type=device_pool" { + t.Errorf("query = %q, want type=device_pool", gotQuery) } - if len(page.Data) != 1 || page.Data[0].Type != firezone.ResourceTypeStaticDevicePool { - t.Errorf("page.Data = %+v, want one static_device_pool Resource", page.Data) + if len(page.Data) != 1 || page.Data[0].Type != firezone.ResourceTypeDevicePool { + t.Errorf("page.Data = %+v, want one device_pool Resource", page.Data) + } +} + +func TestResourcesService_DevicePools(t *testing.T) { + for _, criteria := range []string{ + `{"device":{"field":"id","op":"in","value":[]}}`, + `{"device":{"field":"id","op":"in","value":["11111111-2222-3333-4444-555555555555"]}}`, + `{"device":{"field":"actor_id","op":"eq","value":{"subject":"actor_id"}}}`, + `{"device":{"field":"account_id","op":"eq","value":{"subject":"account_id"}}}`, + `{"actor_group":{"field":"id","op":"eq","value":"11111111-2222-3333-4444-555555555555"}}`, + } { + t.Run(criteria, func(t *testing.T) { + client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost || r.Method == http.MethodPatch { + var body map[string]map[string]json.RawMessage + decodeJSONBody(t, r, &body) + var got, want any + if err := json.Unmarshal(body["resource"]["device_membership_criteria"], &got); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(criteria), &want); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("criteria = %v, want %v", got, want) + } + } + res := map[string]any{"id": "pool-1", "type": "device_pool", "name": "pool", "device_membership_criteria": json.RawMessage(criteria)} + var data any = res + if r.Method == http.MethodGet && r.URL.Path == "/resources" { + data = []any{res} + } + testutil.JSONResponse(http.StatusOK, map[string]any{"data": data})(w, r) + })) + ctx := context.Background() + created, err := client.Resources.Create(ctx, &firezone.CreateResourceRequest{Name: "pool", Type: firezone.ResourceTypeDevicePool, DeviceMembershipCriteria: json.RawMessage(criteria)}) + if err != nil { + t.Fatal(err) + } + updated, err := client.Resources.Update(ctx, created.ID, &firezone.UpdateResourceRequest{DeviceMembershipCriteria: json.RawMessage(criteria)}) + if err != nil { + t.Fatal(err) + } + found, err := client.Resources.Get(ctx, created.ID) + if err != nil { + t.Fatal(err) + } + page, err := client.Resources.List(ctx, nil) + if err != nil { + t.Fatal(err) + } + for _, res := range []*firezone.Resource{created, updated, found, &page.Data[0]} { + if res.Type != firezone.ResourceTypeDevicePool || len(res.DeviceMembershipCriteria) == 0 { + t.Fatalf("lost device pool fields: %+v", res) + } + } + }) } } diff --git a/testdata/openapi.json b/testdata/openapi.json index c7c5ae8..2ebba5a 100644 --- a/testdata/openapi.json +++ b/testdata/openapi.json @@ -655,12 +655,12 @@ "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.", + "description": "POST body for creating a Resource. `site_id` is required for every type except `device_pool`, which needs `device_membership_criteria` instead.", "properties": { "resource": { "properties": { "address": { - "description": "Resource address.", + "description": "Resource address. Required for `cidr`, `ip` and `dns`. `device_pool` and `internet` Resources ignore it.", "example": "10.0.0.10", "nullable": true, "type": "string" @@ -671,6 +671,9 @@ "nullable": true, "type": "string" }, + "device_membership_criteria": { + "$ref": "#/components/schemas/DeviceMembershipCriteria" + }, "filters": { "description": "Traffic filters restricting the protocols and ports the Resource exposes", "example": [ @@ -702,7 +705,7 @@ "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.", + "description": "Site to connect the Resource to. Required for all types except `device_pool`, which takes none. The Internet Site is reserved for the Internet Resource and cannot be used.", "example": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", "format": "uuid", "nullable": true, @@ -710,12 +713,13 @@ "type": "string" }, "type": { - "description": "Resource type. `internet` is accepted only in the Internet Site.", + "description": "Resource type. `internet` is accepted only in the Internet Site. `device_pool` takes no `site_id` and no `address`.", "enum": [ "cidr", "ip", "dns", - "internet" + "internet", + "device_pool" ], "example": "ip", "type": "string" @@ -4228,7 +4232,7 @@ "description": "Resource", "properties": { "address": { - "description": "Resource address. Null for `static_device_pool` Resources.", + "description": "Resource address. Null for `device_pool` Resources.", "example": "10.0.0.10", "nullable": true, "type": "string" @@ -4239,6 +4243,9 @@ "nullable": true, "type": "string" }, + "device_membership_criteria": { + "$ref": "#/components/schemas/DeviceMembershipCriteria" + }, "filters": { "description": "Traffic filters restricting the protocols and ports the Resource exposes", "example": [ @@ -4275,21 +4282,20 @@ "type": "string" }, "site_id": { - "description": "Site to connect the Resource to. Required for all types except `static_device_pool`.", + "description": "Site to connect the Resource to. Required for all types except device pools.", "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.", + "description": "Resource type. A `device_pool` has no `address` and no `site_id`; who it holds is `device_membership_criteria`.", "enum": [ "cidr", "ip", "dns", "internet", - "static_device_pool", - "dynamic_device_pool" + "device_pool" ], "example": "ip", "type": "string" @@ -5052,6 +5058,7 @@ "type": "object" }, "PoolMemberPutRequest": { + "deprecated": true, "description": "PUT body replacing the pool's entire membership. Any Client not named\nhere is removed from the pool.\n", "properties": { "pool_members": { @@ -5240,7 +5247,7 @@ "resource": { "properties": { "address": { - "description": "Resource address.", + "description": "Resource address. Required for `cidr`, `ip` and `dns`. `device_pool` and `internet` Resources ignore it.", "example": "10.0.0.10", "nullable": true, "type": "string" @@ -5251,6 +5258,9 @@ "nullable": true, "type": "string" }, + "device_membership_criteria": { + "$ref": "#/components/schemas/DeviceMembershipCriteria" + }, "filters": { "description": "Traffic filters restricting the protocols and ports the Resource exposes", "example": [ @@ -5282,7 +5292,7 @@ "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.", + "description": "Site to connect the Resource to. Required for all types except `device_pool`, which takes none. The Internet Site is reserved for the Internet Resource and cannot be used.", "example": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", "format": "uuid", "nullable": true, @@ -5290,13 +5300,13 @@ "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.", + "description": "Resource type. `internet` is accepted only in the Internet Site. `device_pool` takes no `site_id` and no `address`.", "enum": [ "cidr", "ip", "dns", "internet", - "static_device_pool" + "device_pool" ], "example": "ip", "type": "string" @@ -5473,7 +5483,8 @@ "type": "object" }, "PoolMember": { - "description": "A Client belonging to a static device pool Resource", + "deprecated": true, + "description": "A Client a device pool Resource names as a member", "properties": { "id": { "description": "Client ID", @@ -6150,6 +6161,7 @@ "type": "object" }, "PoolMemberPatchRequest": { + "deprecated": true, "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": { @@ -7144,6 +7156,450 @@ }, "title": "OIDCAuthProviderResponse", "type": "object" + }, + "DeviceMembershipAllDevices": { + "additionalProperties": false, + "description": "Every Client in the Account", + "example": { + "device": { + "field": "account_id", + "op": "eq", + "value": { + "subject": "account_id" + } + } + }, + "properties": { + "device": { + "additionalProperties": false, + "properties": { + "field": { + "enum": [ + "account_id" + ], + "type": "string" + }, + "op": { + "enum": [ + "eq" + ], + "type": "string" + }, + "value": { + "additionalProperties": false, + "properties": { + "subject": { + "enum": [ + "account_id" + ], + "type": "string" + } + }, + "required": [ + "subject" + ], + "type": "object" + } + }, + "required": [ + "field", + "op", + "value" + ], + "type": "object" + } + }, + "required": [ + "device" + ], + "title": "DeviceMembershipAllDevices", + "type": "object" + }, + "DeviceMembershipActorGroup": { + "additionalProperties": false, + "description": "The Clients of every Actor in one Group", + "example": { + "actor_group": { + "field": "id", + "op": "eq", + "value": "b3a1c6e2-5f4d-4e7a-9c8b-1d2e3f4a5b6c" + } + }, + "properties": { + "actor_group": { + "additionalProperties": false, + "properties": { + "field": { + "enum": [ + "id" + ], + "type": "string" + }, + "op": { + "enum": [ + "eq" + ], + "type": "string" + }, + "value": { + "description": "Group ID", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "field", + "op", + "value" + ], + "type": "object" + } + }, + "required": [ + "actor_group" + ], + "title": "DeviceMembershipActorGroup", + "type": "object" + }, + "DeviceMembershipListedDevices": { + "additionalProperties": false, + "description": "Exactly the Clients named", + "example": { + "device": { + "field": "id", + "op": "in", + "value": [ + "7cb89288-1fb3-433e-a522-2d087e45988d", + "cc9f561a-444d-4083-ab38-0abc6cf2314c" + ] + } + }, + "properties": { + "device": { + "additionalProperties": false, + "properties": { + "field": { + "enum": [ + "id" + ], + "type": "string" + }, + "op": { + "enum": [ + "in" + ], + "type": "string" + }, + "value": { + "description": "Client IDs", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "field", + "op", + "value" + ], + "type": "object" + } + }, + "required": [ + "device" + ], + "title": "DeviceMembershipListedDevices", + "type": "object" + }, + "DeviceMembershipOwnDevices": { + "additionalProperties": false, + "description": "The Clients of the Actor asking for access", + "example": { + "device": { + "field": "actor_id", + "op": "eq", + "value": { + "subject": "actor_id" + } + } + }, + "properties": { + "device": { + "additionalProperties": false, + "properties": { + "field": { + "enum": [ + "actor_id" + ], + "type": "string" + }, + "op": { + "enum": [ + "eq" + ], + "type": "string" + }, + "value": { + "additionalProperties": false, + "properties": { + "subject": { + "enum": [ + "actor_id" + ], + "type": "string" + } + }, + "required": [ + "subject" + ], + "type": "object" + } + }, + "required": [ + "field", + "op", + "value" + ], + "type": "object" + } + }, + "required": [ + "device" + ], + "title": "DeviceMembershipOwnDevices", + "type": "object" + }, + "DeviceMembershipCriteria": { + "description": "Who a `device_pool` Resource holds. Required for a device pool, absent for every\nother Resource type.\n\nThe object has exactly one key, naming where the field lives: `device` is the\nClient row itself, and `actor_group` is a group the Client's owner belongs to.\nThe value is one rule comparing a field against either a literal or an attribute\nof the Actor asking for access. More sources can be added later without changing\nthe rules already stored.\n\nThe four rules the API accepts, and who each one holds:\n\n {\"device\": {\"field\": \"id\", \"op\": \"in\", \"value\": [\"\", ...]}}\n {\"device\": {\"field\": \"actor_id\", \"op\": \"eq\", \"value\": {\"subject\": \"actor_id\"}}}\n {\"device\": {\"field\": \"account_id\", \"op\": \"eq\", \"value\": {\"subject\": \"account_id\"}}}\n {\"actor_group\": {\"field\": \"id\", \"op\": \"eq\", \"value\": \"\"}}\n\nIn order: exactly the Clients named, the asking Actor's own Clients, every Client\nin the Account, and the Clients of every Actor in one Group.\n\nEditing the list of Clients the first rule names drops the connections to the\nClients removed and leaves every other connection through the pool alone.\n\nEvery other change to this field is a breaking update, because any Client can move\nin or out of the pool: every active connection through the pool is dropped, and\nClients reconnect to the members they may still reach.\n\nClients older than the device-pool protocol only understand a pool that names its\nmembers, so they are sent the first rule and never the other three.\n", + "example": { + "device": { + "field": "actor_id", + "op": "eq", + "value": { + "subject": "actor_id" + } + } + }, + "oneOf": [ + { + "additionalProperties": false, + "description": "Exactly the Clients named", + "example": { + "device": { + "field": "id", + "op": "in", + "value": [ + "7cb89288-1fb3-433e-a522-2d087e45988d", + "cc9f561a-444d-4083-ab38-0abc6cf2314c" + ] + } + }, + "properties": { + "device": { + "additionalProperties": false, + "properties": { + "field": { + "enum": [ + "id" + ], + "type": "string" + }, + "op": { + "enum": [ + "in" + ], + "type": "string" + }, + "value": { + "description": "Client IDs", + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "field", + "op", + "value" + ], + "type": "object" + } + }, + "required": [ + "device" + ], + "title": "DeviceMembershipListedDevices", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The Clients of the Actor asking for access", + "example": { + "device": { + "field": "actor_id", + "op": "eq", + "value": { + "subject": "actor_id" + } + } + }, + "properties": { + "device": { + "additionalProperties": false, + "properties": { + "field": { + "enum": [ + "actor_id" + ], + "type": "string" + }, + "op": { + "enum": [ + "eq" + ], + "type": "string" + }, + "value": { + "additionalProperties": false, + "properties": { + "subject": { + "enum": [ + "actor_id" + ], + "type": "string" + } + }, + "required": [ + "subject" + ], + "type": "object" + } + }, + "required": [ + "field", + "op", + "value" + ], + "type": "object" + } + }, + "required": [ + "device" + ], + "title": "DeviceMembershipOwnDevices", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Every Client in the Account", + "example": { + "device": { + "field": "account_id", + "op": "eq", + "value": { + "subject": "account_id" + } + } + }, + "properties": { + "device": { + "additionalProperties": false, + "properties": { + "field": { + "enum": [ + "account_id" + ], + "type": "string" + }, + "op": { + "enum": [ + "eq" + ], + "type": "string" + }, + "value": { + "additionalProperties": false, + "properties": { + "subject": { + "enum": [ + "account_id" + ], + "type": "string" + } + }, + "required": [ + "subject" + ], + "type": "object" + } + }, + "required": [ + "field", + "op", + "value" + ], + "type": "object" + } + }, + "required": [ + "device" + ], + "title": "DeviceMembershipAllDevices", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The Clients of every Actor in one Group", + "example": { + "actor_group": { + "field": "id", + "op": "eq", + "value": "b3a1c6e2-5f4d-4e7a-9c8b-1d2e3f4a5b6c" + } + }, + "properties": { + "actor_group": { + "additionalProperties": false, + "properties": { + "field": { + "enum": [ + "id" + ], + "type": "string" + }, + "op": { + "enum": [ + "eq" + ], + "type": "string" + }, + "value": { + "description": "Group ID", + "format": "uuid", + "type": "string" + } + }, + "required": [ + "field", + "op", + "value" + ], + "type": "object" + } + }, + "required": [ + "actor_group" + ], + "title": "DeviceMembershipActorGroup", + "type": "object" + } + ], + "title": "DeviceMembershipCriteria", + "type": "object" } }, "securitySchemes": { @@ -12325,7 +12781,7 @@ } }, { - "description": "Filter to Resources of this type: cidr, ip, dns, or static_device_pool.", + "description": "Filter to Resources of this type: cidr, ip, dns, or device_pool.", "example": "dns", "in": "query", "name": "type", @@ -12367,6 +12823,54 @@ "200": { "content": { "application/json": { + "examples": { + "resources": { + "summary": "An IP Resource and a device pool", + "value": { + "data": [ + { + "address": "10.0.0.10", + "address_description": "Production Database", + "filters": [ + { + "ports": [ + "5432" + ], + "protocol": "tcp" + } + ], + "id": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name": "Prod DB", + "site_id": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "type": "ip" + }, + { + "address": null, + "address_description": null, + "device_membership_criteria": { + "device": { + "field": "actor_id", + "op": "eq", + "value": { + "subject": "actor_id" + } + } + }, + "filters": [], + "id": "5f0d3c1a-8e2b-4b7d-9a6c-3e4f5a6b7c8d", + "name": "My Devices", + "type": "device_pool" + } + ], + "metadata": { + "count": 2, + "limit": 10, + "next_page": null, + "prev_page": null + } + } + } + }, "schema": { "$ref": "#/components/schemas/ResourceListResponse" } @@ -12451,6 +12955,99 @@ "requestBody": { "content": { "application/json": { + "examples": { + "actor_group": { + "summary": "Device pool of the Clients of every Actor in one Group", + "value": { + "resource": { + "device_membership_criteria": { + "actor_group": { + "field": "id", + "op": "eq", + "value": "b3a1c6e2-5f4d-4e7a-9c8b-1d2e3f4a5b6c" + } + }, + "name": "Engineering Devices", + "type": "device_pool" + } + } + }, + "all_devices": { + "summary": "Device pool of every Client in the Account", + "value": { + "resource": { + "device_membership_criteria": { + "device": { + "field": "account_id", + "op": "eq", + "value": { + "subject": "account_id" + } + } + }, + "name": "All Devices", + "type": "device_pool" + } + } + }, + "ip": { + "summary": "IP Resource", + "value": { + "resource": { + "address": "10.0.0.10", + "address_description": "Production Database", + "filters": [ + { + "ports": [ + "5432" + ], + "protocol": "tcp" + } + ], + "name": "Prod DB", + "site_id": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "type": "ip" + } + } + }, + "listed_devices": { + "summary": "Device pool of the Clients named", + "value": { + "resource": { + "device_membership_criteria": { + "device": { + "field": "id", + "op": "in", + "value": [ + "7cb89288-1fb3-433e-a522-2d087e45988d", + "cc9f561a-444d-4083-ab38-0abc6cf2314c" + ] + } + }, + "name": "Build Machines", + "type": "device_pool" + } + } + }, + "own_devices": { + "summary": "Device pool of the asking Actor's own Clients", + "value": { + "resource": { + "device_membership_criteria": { + "device": { + "field": "actor_id", + "op": "eq", + "value": { + "subject": "actor_id" + } + } + }, + "name": "My Devices", + "type": "device_pool" + } + } + } + }, "schema": { "$ref": "#/components/schemas/ResourceCreateRequest" } @@ -12463,6 +13060,51 @@ "201": { "content": { "application/json": { + "examples": { + "device_pool": { + "summary": "Device pool", + "value": { + "data": { + "address": null, + "address_description": null, + "device_membership_criteria": { + "device": { + "field": "actor_id", + "op": "eq", + "value": { + "subject": "actor_id" + } + } + }, + "filters": [], + "id": "5f0d3c1a-8e2b-4b7d-9a6c-3e4f5a6b7c8d", + "name": "My Devices", + "type": "device_pool" + } + } + }, + "ip": { + "summary": "IP Resource", + "value": { + "data": { + "address": "10.0.0.10", + "address_description": "Production Database", + "filters": [ + { + "ports": [ + "5432" + ], + "protocol": "tcp" + } + ], + "id": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name": "Prod DB", + "site_id": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "type": "ip" + } + } + } + }, "schema": { "$ref": "#/components/schemas/ResourceResponse" } @@ -14606,6 +15248,51 @@ "200": { "content": { "application/json": { + "examples": { + "device_pool": { + "summary": "Device pool", + "value": { + "data": { + "address": null, + "address_description": null, + "device_membership_criteria": { + "device": { + "field": "actor_id", + "op": "eq", + "value": { + "subject": "actor_id" + } + } + }, + "filters": [], + "id": "5f0d3c1a-8e2b-4b7d-9a6c-3e4f5a6b7c8d", + "name": "My Devices", + "type": "device_pool" + } + } + }, + "ip": { + "summary": "IP Resource", + "value": { + "data": { + "address": "10.0.0.10", + "address_description": "Production Database", + "filters": [ + { + "ports": [ + "5432" + ], + "protocol": "tcp" + } + ], + "id": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name": "Prod DB", + "site_id": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "type": "ip" + } + } + } + }, "schema": { "$ref": "#/components/schemas/ResourceResponse" } @@ -14718,6 +15405,51 @@ "200": { "content": { "application/json": { + "examples": { + "device_pool": { + "summary": "Device pool", + "value": { + "data": { + "address": null, + "address_description": null, + "device_membership_criteria": { + "device": { + "field": "actor_id", + "op": "eq", + "value": { + "subject": "actor_id" + } + } + }, + "filters": [], + "id": "5f0d3c1a-8e2b-4b7d-9a6c-3e4f5a6b7c8d", + "name": "My Devices", + "type": "device_pool" + } + } + }, + "ip": { + "summary": "IP Resource", + "value": { + "data": { + "address": "10.0.0.10", + "address_description": "Production Database", + "filters": [ + { + "ports": [ + "5432" + ], + "protocol": "tcp" + } + ], + "id": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name": "Prod DB", + "site_id": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "type": "ip" + } + } + } + }, "schema": { "$ref": "#/components/schemas/ResourceResponse" } @@ -14829,6 +15561,48 @@ "requestBody": { "content": { "application/json": { + "examples": { + "convert_to_device_pool": { + "summary": "Convert a Resource to a device pool", + "value": { + "resource": { + "device_membership_criteria": { + "actor_group": { + "field": "id", + "op": "eq", + "value": "b3a1c6e2-5f4d-4e7a-9c8b-1d2e3f4a5b6c" + } + }, + "type": "device_pool" + } + } + }, + "rename": { + "summary": "Rename a Resource", + "value": { + "resource": { + "name": "Prod DB (primary)" + } + } + }, + "set_pool_members": { + "summary": "Replace the Clients a device pool names", + "value": { + "resource": { + "device_membership_criteria": { + "device": { + "field": "id", + "op": "in", + "value": [ + "7cb89288-1fb3-433e-a522-2d087e45988d", + "cc9f561a-444d-4083-ab38-0abc6cf2314c" + ] + } + } + } + } + } + }, "schema": { "$ref": "#/components/schemas/ResourceUpdateRequest" } @@ -14841,6 +15615,51 @@ "200": { "content": { "application/json": { + "examples": { + "device_pool": { + "summary": "Device pool", + "value": { + "data": { + "address": null, + "address_description": null, + "device_membership_criteria": { + "device": { + "field": "actor_id", + "op": "eq", + "value": { + "subject": "actor_id" + } + } + }, + "filters": [], + "id": "5f0d3c1a-8e2b-4b7d-9a6c-3e4f5a6b7c8d", + "name": "My Devices", + "type": "device_pool" + } + } + }, + "ip": { + "summary": "IP Resource", + "value": { + "data": { + "address": "10.0.0.10", + "address_description": "Production Database", + "filters": [ + { + "ports": [ + "5432" + ], + "protocol": "tcp" + } + ], + "id": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name": "Prod DB", + "site_id": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "type": "ip" + } + } + } + }, "schema": { "$ref": "#/components/schemas/ResourceResponse" } @@ -14973,6 +15792,48 @@ "requestBody": { "content": { "application/json": { + "examples": { + "convert_to_device_pool": { + "summary": "Convert a Resource to a device pool", + "value": { + "resource": { + "device_membership_criteria": { + "actor_group": { + "field": "id", + "op": "eq", + "value": "b3a1c6e2-5f4d-4e7a-9c8b-1d2e3f4a5b6c" + } + }, + "type": "device_pool" + } + } + }, + "rename": { + "summary": "Rename a Resource", + "value": { + "resource": { + "name": "Prod DB (primary)" + } + } + }, + "set_pool_members": { + "summary": "Replace the Clients a device pool names", + "value": { + "resource": { + "device_membership_criteria": { + "device": { + "field": "id", + "op": "in", + "value": [ + "7cb89288-1fb3-433e-a522-2d087e45988d", + "cc9f561a-444d-4083-ab38-0abc6cf2314c" + ] + } + } + } + } + } + }, "schema": { "$ref": "#/components/schemas/ResourceUpdateRequest" } @@ -14985,6 +15846,51 @@ "200": { "content": { "application/json": { + "examples": { + "device_pool": { + "summary": "Device pool", + "value": { + "data": { + "address": null, + "address_description": null, + "device_membership_criteria": { + "device": { + "field": "actor_id", + "op": "eq", + "value": { + "subject": "actor_id" + } + } + }, + "filters": [], + "id": "5f0d3c1a-8e2b-4b7d-9a6c-3e4f5a6b7c8d", + "name": "My Devices", + "type": "device_pool" + } + } + }, + "ip": { + "summary": "IP Resource", + "value": { + "data": { + "address": "10.0.0.10", + "address_description": "Production Database", + "filters": [ + { + "ports": [ + "5432" + ], + "protocol": "tcp" + } + ], + "id": "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "name": "Prod DB", + "site_id": "0642e09d-b3a2-47e4-9cd1-c2195faeeb67", + "type": "ip" + } + } + } + }, "schema": { "$ref": "#/components/schemas/ResourceResponse" } @@ -15998,7 +16904,8 @@ "/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", + "deprecated": true, + "description": "Deprecated. Read `device_membership_criteria` on the Resource instead, which\nalso describes the pools that pick their members by a rule.\n\nLists the Clients a `device_pool` Resource names as its members.\n\nReturns 400 for any other Resource type, and for a device pool whose members are\npicked by a rule rather than named.\n", "operationId": "PortalAPI.PoolMemberController.index", "parameters": [ { @@ -16132,7 +17039,8 @@ }, "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", + "deprecated": true, + "description": "Deprecated. Write `device_membership_criteria` on the Resource instead.\n\nAdds 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\nRemoving a Client drops its connections through the pool. Every other connection\nthrough the pool is left alone.\n", "operationId": "PortalAPI.PoolMemberController.update_patch", "parameters": [ { @@ -16277,7 +17185,8 @@ }, "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", + "deprecated": true, + "description": "Deprecated. Write `device_membership_criteria` on the Resource instead.\n\nReplaces 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\nRemoving a Client drops its connections through the pool. Every other connection\nthrough the pool is left alone.\n", "operationId": "PortalAPI.PoolMemberController.update_put", "parameters": [ {