2 Commits

Author SHA1 Message Date
unkin-agent ff2dff7af2 Add optional methods attribute to the arrstack role resource (#3)
ci/woodpecker/tag/release Pipeline was successful
## Why

Engine plugin v0.2.0 added a `methods` field to arrstack roles, pinning a minted arrproxy key to a set of HTTP methods so a read-only integration can be handed a key that cannot write. The provider had no way to express it, so those roles could not be managed from `terraform-vault`.

## How

- Adds an optional `methods` set attribute to `arrstack_secret_backend_role`, validated at plan time against `GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS` (upper case only, since the engine stores them upper-cased and a lower-case value would drift on every plan).
- Always writes `methods`: the engine only clears an existing scope when the key is present, so an omitted key would leave a stale scope behind.
- Reads an unrestricted role back as null rather than an empty set, so a config that omits `methods` shows no drift; a scope cleared out of band still surfaces as a diff.
- Documents the attribute in the README and both example configs.
- Tests cover the write mapping (null and populated), the read-back cases (absent/empty/null/cleared), and the plan-time validator.

Dependency: engine plugin >= 0.2.0.
Reviewed-on: #3
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-30 17:05:47 +10:00
unkin-agent 35a1dcf7bb Model apps as a set instead of an ordered list (#2)
ci/woodpecker/tag/release Pipeline was successful
The arrstack engine returns `apps` alphabetically sorted regardless of the order they were written in, so modelling it as an ordered List makes any config whose order differs fail apply with "Provider produced inconsistent result after apply" and produce perpetual re-diffs. `apps` is semantically a set of app names, so it is now modelled as one.

- Changes the `apps` attribute on `arrstack_secret_backend_role` from `types.List`/`schema.ListAttribute` to `types.Set`/`schema.SetAttribute`
- Reads engine responses back via `types.SetValueFrom`
- Updates unit tests for the set type and adds an order-insensitivity test proving a sorted engine response equals a differently-ordered config value
- No other resources or data sources use the List-of-apps pattern

Reviewed-on: #2
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
2026-08-23 00:08:57 +10:00
7 changed files with 258 additions and 15 deletions
+17 -1
View File
@@ -14,7 +14,7 @@ Source address: `artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/vault-secrets
| Resource | Manages | | Resource | Manages |
|----------|---------| |----------|---------|
| `arrstack_secret_backend` | Mounts the engine at a path and writes its `config` (arrproxy base URL, request timeout, seeded admin token, optional CA cert). | | `arrstack_secret_backend` | Mounts the engine at a path and writes its `config` (arrproxy base URL, request timeout, seeded admin token, optional CA cert). |
| `arrstack_secret_backend_role` | A role: `apps` (subset of sonarr/radarr/prowlarr), `ttl`, `max_ttl`. | | `arrstack_secret_backend_role` | A role: `apps` (subset of sonarr/radarr/prowlarr), optional `methods` (HTTP method scope), `ttl`, `max_ttl`. |
## Usage ## Usage
@@ -45,6 +45,16 @@ resource "arrstack_secret_backend_role" "all" {
ttl = 60 ttl = 60
max_ttl = 86400 max_ttl = 86400
} }
# Read-only role: keys minted from it may only issue GET/HEAD.
resource "arrstack_secret_backend_role" "sonarr_ro" {
backend = arrstack_secret_backend.arrstack.path
name = "sonarr-ro"
apps = ["sonarr"]
methods = ["GET", "HEAD"]
ttl = 60
max_ttl = 86400
}
``` ```
### Notes ### Notes
@@ -55,6 +65,12 @@ resource "arrstack_secret_backend_role" "all" {
- Writing `config` makes the engine authenticate against arrproxy as an admin, - Writing `config` makes the engine authenticate against arrproxy as an admin,
so a bad URL or token fails the apply. so a bad URL or token fails the apply.
- `apps` is required; entries must be a subset of `sonarr`, `radarr`, `prowlarr`. - `apps` is required; entries must be a subset of `sonarr`, `radarr`, `prowlarr`.
- `methods` is optional and restricts a minted key to those HTTP methods;
omitting it (or setting `[]`) leaves the key unrestricted. Entries must be
upper case and a subset of `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`,
`OPTIONS` — the engine stores them upper-cased, so a lower-case value would
show permanent drift and is rejected at plan time. Requires engine plugin
`vault-plugin-secrets-arrstack` >= 0.2.0.
## Import ## Import
+10
View File
@@ -39,3 +39,13 @@ resource "arrstack_secret_backend_role" "prowlarr" {
ttl = 60 ttl = 60
max_ttl = 86400 max_ttl = 86400
} }
# Role scoped to read-only traffic across all three apps.
resource "arrstack_secret_backend_role" "readonly" {
backend = arrstack_secret_backend.arrstack.path
name = "readonly"
apps = ["sonarr", "radarr", "prowlarr"]
methods = ["GET", "HEAD"]
ttl = 60
max_ttl = 86400
}
@@ -9,3 +9,15 @@ resource "arrstack_secret_backend_role" "sonarr" {
ttl = 60 # 1m ttl = 60 # 1m
max_ttl = 86400 # 24h max_ttl = 86400 # 24h
} }
# The same role narrowed to read-only traffic: keys minted from it may only
# issue GET/HEAD against Sonarr. Omitting methods leaves a key unrestricted.
resource "arrstack_secret_backend_role" "sonarr_ro" {
backend = arrstack_secret_backend.arrstack.path
name = "sonarr-ro"
apps = ["sonarr"]
methods = ["GET", "HEAD"]
ttl = 60 # 1m
max_ttl = 86400 # 24h
}
+1
View File
@@ -4,6 +4,7 @@ go 1.25
require ( require (
github.com/hashicorp/terraform-plugin-framework v1.15.0 github.com/hashicorp/terraform-plugin-framework v1.15.0
github.com/hashicorp/terraform-plugin-framework-validators v0.16.0
github.com/hashicorp/vault/api v1.15.0 github.com/hashicorp/vault/api v1.15.0
) )
+2
View File
@@ -54,6 +54,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/terraform-plugin-framework v1.15.0 h1:LQ2rsOfmDLxcn5EeIwdXFtr03FVsNktbbBci8cOKdb4= github.com/hashicorp/terraform-plugin-framework v1.15.0 h1:LQ2rsOfmDLxcn5EeIwdXFtr03FVsNktbbBci8cOKdb4=
github.com/hashicorp/terraform-plugin-framework v1.15.0/go.mod h1:hxrNI/GY32KPISpWqlCoTLM9JZsGH3CyYlir09bD/fI= github.com/hashicorp/terraform-plugin-framework v1.15.0/go.mod h1:hxrNI/GY32KPISpWqlCoTLM9JZsGH3CyYlir09bD/fI=
github.com/hashicorp/terraform-plugin-framework-validators v0.16.0 h1:O9QqGoYDzQT7lwTXUsZEtgabeWW96zUBh47Smn2lkFA=
github.com/hashicorp/terraform-plugin-framework-validators v0.16.0/go.mod h1:Bh89/hNmqsEWug4/XWKYBwtnw3tbz5BAy1L1OgvbIaY=
github.com/hashicorp/terraform-plugin-go v0.27.0 h1:ujykws/fWIdsi6oTUT5Or4ukvEan4aN9lY+LOxVP8EE= github.com/hashicorp/terraform-plugin-go v0.27.0 h1:ujykws/fWIdsi6oTUT5Or4ukvEan4aN9lY+LOxVP8EE=
github.com/hashicorp/terraform-plugin-go v0.27.0/go.mod h1:FDa2Bb3uumkTGSkTFpWSOwWJDwA7bf3vdP3ltLDTH6o= github.com/hashicorp/terraform-plugin-go v0.27.0/go.mod h1:FDa2Bb3uumkTGSkTFpWSOwWJDwA7bf3vdP3ltLDTH6o=
github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0=
@@ -4,15 +4,22 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
) )
// validMethods mirrors the engine's knownMethods; the engine normalises to
// upper case, so only the upper-case spelling round-trips without drift.
var validMethods = []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
var ( var (
_ resource.Resource = &secretBackendRoleResource{} _ resource.Resource = &secretBackendRoleResource{}
_ resource.ResourceWithImportState = &secretBackendRoleResource{} _ resource.ResourceWithImportState = &secretBackendRoleResource{}
@@ -25,7 +32,8 @@ type secretBackendRoleResource struct {
type secretBackendRoleModel struct { type secretBackendRoleModel struct {
Backend types.String `tfsdk:"backend"` Backend types.String `tfsdk:"backend"`
Name types.String `tfsdk:"name"` Name types.String `tfsdk:"name"`
Apps types.List `tfsdk:"apps"` Apps types.Set `tfsdk:"apps"`
Methods types.Set `tfsdk:"methods"`
TTL types.Int64 `tfsdk:"ttl"` TTL types.Int64 `tfsdk:"ttl"`
MaxTTL types.Int64 `tfsdk:"max_ttl"` MaxTTL types.Int64 `tfsdk:"max_ttl"`
} }
@@ -56,11 +64,19 @@ func (r *secretBackendRoleResource) Schema(_ context.Context, _ resource.SchemaR
stringplanmodifier.RequiresReplace(), stringplanmodifier.RequiresReplace(),
}, },
}, },
"apps": schema.ListAttribute{ "apps": schema.SetAttribute{
Description: "arr apps a generated key may access (subset of sonarr, radarr, prowlarr).", Description: "arr apps a generated key may access (subset of sonarr, radarr, prowlarr).",
ElementType: types.StringType, ElementType: types.StringType,
Required: true, Required: true,
}, },
"methods": schema.SetAttribute{
Description: "HTTP methods a generated key is limited to (subset of GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS). Omit or leave empty for unrestricted.",
ElementType: types.StringType,
Optional: true,
Validators: []validator.Set{
setvalidator.ValueStringsAre(stringvalidator.OneOf(validMethods...)),
},
},
"ttl": schema.Int64Attribute{ "ttl": schema.Int64Attribute{
Description: "Default lease TTL in seconds for keys generated from this role.", Description: "Default lease TTL in seconds for keys generated from this role.",
Optional: true, Optional: true,
@@ -199,6 +215,14 @@ func roleData(ctx context.Context, m secretBackendRoleModel) (map[string]interfa
diags.Append(m.Apps.ElementsAs(ctx, &apps, false)...) diags.Append(m.Apps.ElementsAs(ctx, &apps, false)...)
data["apps"] = apps data["apps"] = apps
} }
// Always sent: the engine only clears a method scope when the key is
// present, so an omitted key would leave a previous scope in place.
methods := []string{}
if !m.Methods.IsNull() && !m.Methods.IsUnknown() {
diags.Append(m.Methods.ElementsAs(ctx, &methods, false)...)
}
data["methods"] = methods
if !m.TTL.IsNull() && !m.TTL.IsUnknown() { if !m.TTL.IsNull() && !m.TTL.IsUnknown() {
data["ttl"] = m.TTL.ValueInt64() data["ttl"] = m.TTL.ValueInt64()
} }
@@ -212,9 +236,20 @@ func applyRoleData(m *secretBackendRoleModel, role map[string]interface{}) diag.
var diags diag.Diagnostics var diags diag.Diagnostics
apps := toStringSlice(role["apps"]) apps := toStringSlice(role["apps"])
appList, appDiags := types.ListValueFrom(context.Background(), types.StringType, apps) appSet, appDiags := types.SetValueFrom(context.Background(), types.StringType, apps)
diags.Append(appDiags...) diags.Append(appDiags...)
m.Apps = appList m.Apps = appSet
// An unrestricted role reads back as an empty list; keep that as null so a
// config that omits methods does not drift against an empty set.
methods := toStringSlice(role["methods"])
if len(methods) == 0 && (m.Methods.IsNull() || m.Methods.IsUnknown()) {
m.Methods = types.SetNull(types.StringType)
} else {
methodSet, methodDiags := types.SetValueFrom(context.Background(), types.StringType, methods)
diags.Append(methodDiags...)
m.Methods = methodSet
}
if n, ok := toInt64(role["ttl"]); ok && n != 0 { if n, ok := toInt64(role["ttl"]); ok && n != 0 {
m.TTL = types.Int64Value(n) m.TTL = types.Int64Value(n)
@@ -3,23 +3,28 @@ package provider
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"sort"
"testing" "testing"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types"
) )
func listOf(t *testing.T, vals ...string) types.List { func setOf(t *testing.T, vals ...string) types.Set {
t.Helper() t.Helper()
l, diags := types.ListValueFrom(context.Background(), types.StringType, vals) s, diags := types.SetValueFrom(context.Background(), types.StringType, vals)
if diags.HasError() { if diags.HasError() {
t.Fatalf("building list: %v", diags) t.Fatalf("building set: %v", diags)
} }
return l return s
} }
func TestRoleDataOmitsUnsetTTLs(t *testing.T) { func TestRoleDataOmitsUnsetTTLs(t *testing.T) {
m := secretBackendRoleModel{ m := secretBackendRoleModel{
Apps: listOf(t, "sonarr", "radarr", "prowlarr"), Apps: setOf(t, "sonarr", "radarr", "prowlarr"),
TTL: types.Int64Null(), TTL: types.Int64Null(),
MaxTTL: types.Int64Null(), MaxTTL: types.Int64Null(),
} }
@@ -39,9 +44,52 @@ func TestRoleDataOmitsUnsetTTLs(t *testing.T) {
} }
} }
func TestRoleDataSendsEmptyMethodsWhenNull(t *testing.T) {
// The engine only clears a method scope when the key is present, so an
// unset methods must still be written as an empty list.
m := secretBackendRoleModel{
Apps: setOf(t, "sonarr"),
Methods: types.SetNull(types.StringType),
TTL: types.Int64Null(),
MaxTTL: types.Int64Null(),
}
data, diags := roleData(context.Background(), m)
if diags.HasError() {
t.Fatalf("roleData: %v", diags)
}
methods, ok := data["methods"].([]string)
if !ok {
t.Fatalf("methods = %#v, want an empty []string", data["methods"])
}
if len(methods) != 0 {
t.Errorf("methods = %v, want empty", methods)
}
}
func TestRoleDataIncludesMethods(t *testing.T) {
m := secretBackendRoleModel{
Apps: setOf(t, "sonarr"),
Methods: setOf(t, "GET", "HEAD"),
TTL: types.Int64Null(),
MaxTTL: types.Int64Null(),
}
data, diags := roleData(context.Background(), m)
if diags.HasError() {
t.Fatalf("roleData: %v", diags)
}
methods, ok := data["methods"].([]string)
if !ok || len(methods) != 2 {
t.Fatalf("methods = %#v, want [GET HEAD]", data["methods"])
}
sort.Strings(methods)
if methods[0] != "GET" || methods[1] != "HEAD" {
t.Errorf("methods = %v, want [GET HEAD]", methods)
}
}
func TestRoleDataIncludesTTLs(t *testing.T) { func TestRoleDataIncludesTTLs(t *testing.T) {
m := secretBackendRoleModel{ m := secretBackendRoleModel{
Apps: listOf(t, "prowlarr"), Apps: setOf(t, "prowlarr"),
TTL: types.Int64Value(60), TTL: types.Int64Value(60),
MaxTTL: types.Int64Value(86400), MaxTTL: types.Int64Value(86400),
} }
@@ -70,16 +118,34 @@ func TestApplyRoleDataMapsEngineResponse(t *testing.T) {
if diags := applyRoleData(&m, role); diags.HasError() { if diags := applyRoleData(&m, role); diags.HasError() {
t.Fatalf("applyRoleData: %v", diags) t.Fatalf("applyRoleData: %v", diags)
} }
var apps []string if !m.Apps.Equal(setOf(t, "sonarr", "radarr", "prowlarr")) {
m.Apps.ElementsAs(context.Background(), &apps, false) t.Errorf("apps = %v, want {sonarr radarr prowlarr}", m.Apps)
if len(apps) != 3 || apps[2] != "prowlarr" {
t.Errorf("apps = %v, want [sonarr radarr prowlarr]", apps)
} }
if m.TTL.ValueInt64() != 60 || m.MaxTTL.ValueInt64() != 86400 { if m.TTL.ValueInt64() != 60 || m.MaxTTL.ValueInt64() != 86400 {
t.Errorf("ttl/max_ttl = %d/%d, want 60/86400", m.TTL.ValueInt64(), m.MaxTTL.ValueInt64()) t.Errorf("ttl/max_ttl = %d/%d, want 60/86400", m.TTL.ValueInt64(), m.MaxTTL.ValueInt64())
} }
} }
func TestApplyRoleDataOrderInsensitive(t *testing.T) {
// The engine returns apps alphabetically sorted regardless of the order
// they were written in; the read-back must still equal the config value.
configApps := setOf(t, "sonarr", "prowlarr", "radarr")
role := map[string]interface{}{
"apps": []interface{}{"prowlarr", "radarr", "sonarr"},
}
m := secretBackendRoleModel{
Apps: configApps,
TTL: types.Int64Null(),
MaxTTL: types.Int64Null(),
}
if diags := applyRoleData(&m, role); diags.HasError() {
t.Fatalf("applyRoleData: %v", diags)
}
if !m.Apps.Equal(configApps) {
t.Errorf("apps after read-back = %v, not equal to config value %v", m.Apps, configApps)
}
}
func TestApplyRoleDataZeroTTLLeavesNull(t *testing.T) { func TestApplyRoleDataZeroTTLLeavesNull(t *testing.T) {
// The engine returns 0 for an unset TTL, which must not clobber the null // The engine returns 0 for an unset TTL, which must not clobber the null
// model value into a spurious 0. // model value into a spurious 0.
@@ -102,3 +168,104 @@ func TestApplyRoleDataZeroTTLLeavesNull(t *testing.T) {
t.Errorf("max_ttl = %v, want null", m.MaxTTL) t.Errorf("max_ttl = %v, want null", m.MaxTTL)
} }
} }
func TestApplyRoleDataMapsMethods(t *testing.T) {
// The engine upper-cases and sorts the method scope it stores.
role := map[string]interface{}{
"apps": []interface{}{"sonarr"},
"methods": []interface{}{"GET", "HEAD"},
}
m := secretBackendRoleModel{
Methods: setOf(t, "HEAD", "GET"),
TTL: types.Int64Null(),
MaxTTL: types.Int64Null(),
}
if diags := applyRoleData(&m, role); diags.HasError() {
t.Fatalf("applyRoleData: %v", diags)
}
if !m.Methods.Equal(setOf(t, "GET", "HEAD")) {
t.Errorf("methods = %v, want {GET HEAD}", m.Methods)
}
}
func TestApplyRoleDataUnrestrictedMethodsStayNull(t *testing.T) {
// An unrestricted role reads back as an empty/absent list, which must not
// clobber a null config value into an empty set and show permanent drift.
for name, role := range map[string]map[string]interface{}{
"absent": {"apps": []interface{}{"sonarr"}},
"empty": {"apps": []interface{}{"sonarr"}, "methods": []interface{}{}},
"null": {"apps": []interface{}{"sonarr"}, "methods": nil},
} {
t.Run(name, func(t *testing.T) {
m := secretBackendRoleModel{
Methods: types.SetNull(types.StringType),
TTL: types.Int64Null(),
MaxTTL: types.Int64Null(),
}
if diags := applyRoleData(&m, role); diags.HasError() {
t.Fatalf("applyRoleData: %v", diags)
}
if !m.Methods.IsNull() {
t.Errorf("methods = %v, want null", m.Methods)
}
})
}
}
func TestApplyRoleDataClearedMethodsShowDrift(t *testing.T) {
// A scope removed out of band must land in state as empty, so the next plan
// proposes putting the configured methods back.
role := map[string]interface{}{"apps": []interface{}{"sonarr"}}
m := secretBackendRoleModel{
Methods: setOf(t, "GET"),
TTL: types.Int64Null(),
MaxTTL: types.Int64Null(),
}
if diags := applyRoleData(&m, role); diags.HasError() {
t.Fatalf("applyRoleData: %v", diags)
}
if m.Methods.IsNull() || len(m.Methods.Elements()) != 0 {
t.Errorf("methods = %v, want an empty set", m.Methods)
}
}
func TestRoleSchemaMethodsValidation(t *testing.T) {
ctx := context.Background()
var resp resource.SchemaResponse
NewSecretBackendRoleResource().(*secretBackendRoleResource).Schema(ctx, resource.SchemaRequest{}, &resp)
attr, ok := resp.Schema.Attributes["methods"].(schema.SetAttribute)
if !ok {
t.Fatalf("methods attribute = %#v, want a schema.SetAttribute", resp.Schema.Attributes["methods"])
}
if attr.IsRequired() || !attr.IsOptional() {
t.Errorf("methods should be optional, not required")
}
if len(attr.Validators) == 0 {
t.Fatal("methods should carry a value validator")
}
for _, tc := range []struct {
name string
values []string
wantErr bool
}{
{name: "allowed", values: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}},
{name: "unknown method", values: []string{"GET", "FETCH"}, wantErr: true},
{name: "lower case", values: []string{"get"}, wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
req := validator.SetRequest{
Path: path.Root("methods"),
ConfigValue: setOf(t, tc.values...),
}
var vResp validator.SetResponse
for _, v := range attr.Validators {
v.ValidateSet(ctx, req, &vResp)
}
if got := vResp.Diagnostics.HasError(); got != tc.wantErr {
t.Errorf("validation error = %v, want %v (%v)", got, tc.wantErr, vResp.Diagnostics)
}
})
}
}