diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a02eb6..8b05c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ they will require a new major version. ## [Unreleased] +## [0.1.1] - 2026-09-10 + +### Added + +- `CreatePolicyRequest.IsDisabled`, so a Policy can be created disabled + rather than created and then disabled in a second call. Omitting it + creates the Policy enabled, which is the API's default. +- `ConditionPropertyDeviceAttested`, the `device_attested` Policy + condition property, which matches when the Client presented a valid + X.509 certificate from one of the account's trust anchors. + ## [0.1.0] - 2026-09-04 First public release. The API is complete and verified against a live @@ -74,5 +85,6 @@ is no code difference to migrate: change the import path to process-global, so sharing it would mean SDK transport changes leaking into the rest of the binary and vice versa. -[Unreleased]: https://github.com/firezone/firezone-sdk-go/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/firezone/firezone-sdk-go/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/firezone/firezone-sdk-go/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/firezone/firezone-sdk-go/releases/tag/v0.1.0 diff --git a/firezone.go b/firezone.go index bdac30f..ec1014c 100644 --- a/firezone.go +++ b/firezone.go @@ -56,7 +56,7 @@ func (b requestBody) reader() io.Reader { // 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" +const Version = "0.1.1" // defaultUserAgent identifies this SDK and its version, plus the Go // runtime it was built with - the latter is worth having when a diff --git a/integration_test.go b/integration_test.go index ca561ed..cb2bef4 100644 --- a/integration_test.go +++ b/integration_test.go @@ -751,6 +751,48 @@ func TestIntegration_PolicyCRUD(t *testing.T) { } } +// TestIntegration_PolicyCreateDisabled checks the create-time +// is_disabled flag against the real API. A unit test can only prove the +// key is sent; the server's changeset silently drops a key it does not +// cast, so only a real create proves the Policy comes back disabled. +func TestIntegration_PolicyCreateDisabled(t *testing.T) { + c := integrationClient(t) + site := newSite(t, c) + group := newGroup(t, c) + resource := newResource(t, c, site.ID) + + disabled := true + policy, err := c.Policies.Create(ctx(), &firezone.CreatePolicyRequest{ + GroupID: group.ID, + ResourceID: resource.ID, + IsDisabled: &disabled, + }) + if err != nil { + t.Fatalf("Create disabled: %v", err) + } + cleanup(t, "Policy "+policy.ID, func() error { return c.Policies.Delete(ctx(), policy.ID) }) + + if !policy.IsDisabled { + t.Error("IsDisabled = false on the created Policy, want true") + } + + fetched, err := c.Policies.Get(ctx(), policy.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if !fetched.IsDisabled { + t.Error("IsDisabled = false after Get, want the Policy to stay disabled") + } + + 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") + } +} + func TestIntegration_GroupCRUD(t *testing.T) { c := integrationClient(t) diff --git a/policies.go b/policies.go index b46bb82..11e16b8 100644 --- a/policies.go +++ b/policies.go @@ -13,6 +13,7 @@ const ( ConditionPropertyAuthProviderID ConditionProperty = "auth_provider_id" ConditionPropertyCurrentUTCDatetime ConditionProperty = "current_utc_datetime" ConditionPropertyClientVerified ConditionProperty = "client_verified" + ConditionPropertyDeviceAttested ConditionProperty = "device_attested" ) // ConditionOperator is the comparison a policy [Condition] applies @@ -60,6 +61,10 @@ const ( // [ConditionOperatorIsInDayOfWeekTimeRanges]. // - [ConditionPropertyClientVerified] with is: Values is a // single-element list holding "true" or "false". +// - [ConditionPropertyDeviceAttested] with is: Values is a +// single-element list holding "true" or "false". "true" requires +// the Client to have presented a valid X.509 certificate from one +// of the account's trust anchors on its current connection. type Condition struct { Property ConditionProperty `json:"property"` Operator ConditionOperator `json:"operator"` @@ -84,6 +89,7 @@ type CreatePolicyRequest struct { ResourceID string `json:"resource_id"` Description string `json:"description,omitempty"` FlowLogUploadsEnabled *bool `json:"flow_log_uploads_enabled,omitempty"` + IsDisabled *bool `json:"is_disabled,omitempty"` Conditions []Condition `json:"conditions,omitempty"` } diff --git a/policies_test.go b/policies_test.go index 4a10eb8..393f84d 100644 --- a/policies_test.go +++ b/policies_test.go @@ -51,6 +51,120 @@ func TestPoliciesService_Create(t *testing.T) { } } +func TestPoliciesService_Create_DeviceAttestedCondition(t *testing.T) { + var gotBody map[string]any + client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + decodeJSONBody(t, r, &gotBody) + testutil.JSONResponse(http.StatusCreated, map[string]any{ + "data": map[string]any{ + "id": "pol-1", "group_id": "group-1", "resource_id": "res-1", + "flow_log_uploads_enabled": true, + "conditions": []map[string]any{ + {"property": "device_attested", "operator": "is", "values": []string{"true"}}, + }, + }, + })(w, r) + })) + + policy, err := client.Policies.Create(context.Background(), &firezone.CreatePolicyRequest{ + GroupID: "group-1", + ResourceID: "res-1", + Conditions: []firezone.Condition{{ + Property: firezone.ConditionPropertyDeviceAttested, + Operator: firezone.ConditionOperatorIs, + Values: []string{"true"}, + }}, + }) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + + reqPolicy, ok := gotBody["policy"].(map[string]any) + if !ok { + t.Fatalf("body[\"policy\"] = %v, want an object", gotBody["policy"]) + } + conds, ok := reqPolicy["conditions"].([]any) + if !ok || len(conds) != 1 { + t.Fatalf("body policy.conditions = %v, want 1 condition", reqPolicy["conditions"]) + } + cond, ok := conds[0].(map[string]any) + if !ok { + t.Fatalf("condition = %v, want an object", conds[0]) + } + if cond["property"] != "device_attested" { + t.Errorf("condition.property = %v, want device_attested", cond["property"]) + } + + if len(policy.Conditions) != 1 || policy.Conditions[0].Property != firezone.ConditionPropertyDeviceAttested { + t.Errorf("policy.Conditions = %+v, want a single device_attested condition", policy.Conditions) + } +} + +func TestPoliciesService_Create_Disabled(t *testing.T) { + var gotBody map[string]any + client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + decodeJSONBody(t, r, &gotBody) + testutil.JSONResponse(http.StatusCreated, map[string]any{ + "data": map[string]any{ + "id": "pol-1", "group_id": "group-1", "resource_id": "res-1", + "flow_log_uploads_enabled": true, "is_disabled": true, + }, + })(w, r) + })) + + disabled := true + policy, err := client.Policies.Create(context.Background(), &firezone.CreatePolicyRequest{ + GroupID: "group-1", + ResourceID: "res-1", + IsDisabled: &disabled, + }) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + + reqPolicy, ok := gotBody["policy"].(map[string]any) + if !ok { + t.Fatalf("body[\"policy\"] = %v, want an object", gotBody["policy"]) + } + if reqPolicy["is_disabled"] != true { + t.Errorf("body.policy.is_disabled = %v, want true", reqPolicy["is_disabled"]) + } + if !policy.IsDisabled { + t.Error("policy.IsDisabled = false, want true") + } +} + +// Omitting IsDisabled must send no key at all: the server defaults the +// field to false, and a false sent explicitly would be indistinguishable +// here but is not what "leave it to the API" means. +func TestPoliciesService_Create_OmitsIsDisabledByDefault(t *testing.T) { + var gotBody map[string]any + client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + decodeJSONBody(t, r, &gotBody) + testutil.JSONResponse(http.StatusCreated, map[string]any{ + "data": map[string]any{ + "id": "pol-1", "group_id": "group-1", "resource_id": "res-1", + "flow_log_uploads_enabled": true, "is_disabled": false, + }, + })(w, r) + })) + + if _, err := client.Policies.Create(context.Background(), &firezone.CreatePolicyRequest{ + GroupID: "group-1", + ResourceID: "res-1", + }); err != nil { + t.Fatalf("Create returned error: %v", err) + } + + reqPolicy, ok := gotBody["policy"].(map[string]any) + if !ok { + t.Fatalf("body[\"policy\"] = %v, want an object", gotBody["policy"]) + } + if _, present := reqPolicy["is_disabled"]; present { + t.Errorf("body.policy.is_disabled = %v, want the key omitted", reqPolicy["is_disabled"]) + } +} + func TestPoliciesService_Create_TimeRangeCondition(t *testing.T) { var gotBody map[string]any client := testutil.NewClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/testdata/openapi.json b/testdata/openapi.json index 9129318..c7c5ae8 100644 --- a/testdata/openapi.json +++ b/testdata/openapi.json @@ -249,6 +249,12 @@ "format": "uuid", "type": "string" }, + "is_disabled": { + "default": false, + "description": "Whether the Policy is disabled. A disabled Policy grants no access but is otherwise retained. Defaults to false.", + "example": false, + "type": "boolean" + }, "resource_id": { "description": "Resource ID", "example": "a9f60587-793c-46ae-8525-597f43ab2fb1", @@ -5658,7 +5664,7 @@ "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", + "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* `device_attested` with `is`: `values` is a single-element list\n containing `\"true\"` or `\"false\"`. `\"true\"` requires the Client to\n have presented a valid X.509 certificate from one of the account's\n trust anchors on its current connection.\n", "properties": { "operator": { "description": "How the values are compared against the property", @@ -5680,7 +5686,8 @@ "remote_ip", "auth_provider_id", "current_utc_datetime", - "client_verified" + "client_verified", + "device_attested" ], "example": "remote_ip_location_region", "type": "string" @@ -5924,13 +5931,13 @@ "description": "A single API Request Log entry, recording one authenticated REST API\nrequest.\n", "properties": { "actor_id": { - "description": "ID of the API Client actor.", + "description": "ID of the actor making the API or MCP request.", "example": "84e7f82f-831a-4a9d-8f17-c66c2bb6e205", "format": "uuid", "type": "string" }, "api_token_id": { - "description": "ID of the API token used.", + "description": "ID of the API token or MCP OAuth access token used.", "example": "44e7f82f-831a-4a9d-8f17-c66c2bb6e205", "format": "uuid", "type": "string" @@ -5969,6 +5976,12 @@ "example": "a00060db0c2c8eb400000000", "type": "string" }, + "mcp": { + "additionalProperties": true, + "description": "MCP execution metadata, null for REST requests. tool_name is the\nrequested tool, not proof of execution. outcome is received, rejected,\nignored, protocol_response, dispatched, succeeded, or failed. A\ndispatched outcome without completion has an unknown result. method\nand path identify the dispatched REST operation; rest_status is its\nstatus and http_status is the outer response status. No arguments or\nresponse bodies are logged. A succeeded result means the REST handler\nreturned 2xx, not necessarily that it changed data.\n", + "nullable": true, + "type": "object" + }, "method": { "description": "HTTP request method.", "example": "GET", @@ -6014,6 +6027,7 @@ "ip_region", "log_id", "method", + "mcp", "path", "request_id", "timestamp", @@ -7158,6 +7172,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -7418,6 +7434,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -8610,6 +8628,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -8717,6 +8737,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -8956,6 +8978,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -9814,6 +9838,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -10327,6 +10353,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -10443,6 +10471,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -10672,6 +10702,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -11419,6 +11451,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -12267,6 +12301,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -12783,6 +12819,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -12890,6 +12928,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -13344,6 +13384,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -13461,6 +13503,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -13874,6 +13918,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -14329,6 +14375,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -15064,6 +15112,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -15294,6 +15344,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -15846,6 +15898,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -15964,6 +16018,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -16831,6 +16887,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -17691,6 +17749,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -17985,6 +18045,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -18092,6 +18154,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -18206,6 +18270,7 @@ }, "post": { "callbacks": {}, + "description": "Creates a Policy.\n\nA Policy is enabled by default. Pass `is_disabled: true` to create it disabled, so it grants no access until enabled.\n", "operationId": "PortalAPI.PolicyController.create", "parameters": [], "requestBody": { @@ -18355,12 +18420,14 @@ } }, { - "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", + "description": "Maximum number of Logs to return per page, from 1 to 100. Defaults to 50.\n", "example": 50, "in": "query", "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -18508,6 +18575,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -18760,6 +18829,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -18988,6 +19059,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } }, @@ -19474,6 +19547,8 @@ "name": "limit", "required": false, "schema": { + "maximum": 100, + "minimum": 1, "type": "integer" } },