Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
58 changes: 30 additions & 28 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions pool_members.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
90 changes: 44 additions & 46 deletions resources.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
)

Expand Down Expand Up @@ -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].
Expand All @@ -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
Expand All @@ -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}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
77 changes: 67 additions & 10 deletions resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package firezone_test

import (
"context"
"encoding/json"
"net/http"
"reflect"
"testing"

firezone "github.com/firezone/firezone-sdk-go"
Expand Down Expand Up @@ -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)
}
}
})
}
}
Loading
Loading