From 0cf907be5f92236d73e8aa0e40cf0108fa7ede71 Mon Sep 17 00:00:00 2001 From: Oskar Date: Sun, 23 Oct 2022 16:12:10 +0200 Subject: [PATCH] feat: create users in the build-in radius server (#286) * feat: account resource with data source * Adjust docs and validation * add import test steps * adjust radius capitalization in docs Co-authored-by: Paul Tyng --- docs/data-sources/account.md | 34 ++++ docs/data-sources/radius_profile.md | 2 +- docs/resources/account.md | 45 +++++ docs/resources/radius_profile.md | 10 +- internal/provider/data_account.go | 86 +++++++++ internal/provider/data_account_test.go | 71 ++++++++ internal/provider/data_radius_profile.go | 2 +- internal/provider/lazy_client.go | 30 ++++ internal/provider/provider.go | 8 + internal/provider/resource_account.go | 179 +++++++++++++++++++ internal/provider/resource_account_test.go | 54 ++++++ internal/provider/resource_radius_profile.go | 12 +- 12 files changed, 520 insertions(+), 13 deletions(-) create mode 100644 docs/data-sources/account.md create mode 100644 docs/resources/account.md create mode 100644 internal/provider/data_account.go create mode 100644 internal/provider/data_account_test.go create mode 100644 internal/provider/resource_account.go create mode 100644 internal/provider/resource_account_test.go diff --git a/docs/data-sources/account.md b/docs/data-sources/account.md new file mode 100644 index 0000000..9f3b9aa --- /dev/null +++ b/docs/data-sources/account.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "unifi_account Data Source - terraform-provider-unifi" +subcategory: "" +description: |- + unifi_account data source can be used to retrieve RADIUS user accounts +--- + +# unifi_account (Data Source) + +`unifi_account` data source can be used to retrieve RADIUS user accounts + + + + +## Schema + +### Required + +- `name` (String) The name of the account to look up + +### Optional + +- `site` (String) The name of the site the account is associated with. + +### Read-Only + +- `id` (String) The ID of this account. +- `network_id` (String) ID of the network for this account +- `password` (String, Sensitive) The password of the account. +- `tunnel_medium_type` (Number) See RFC2868 section 3.2 +- `tunnel_type` (Number) See RFC2868 section 3.1 + + diff --git a/docs/data-sources/radius_profile.md b/docs/data-sources/radius_profile.md index be8b5b6..b0f6103 100644 --- a/docs/data-sources/radius_profile.md +++ b/docs/data-sources/radius_profile.md @@ -18,7 +18,7 @@ description: |- ### Optional - `name` (String) The name of the RADIUS profile to look up. Defaults to `Default`. -- `site` (String) The name of the site the radius profile is associated with. +- `site` (String) The name of the site the RADIUS profile is associated with. ### Read-Only diff --git a/docs/resources/account.md b/docs/resources/account.md new file mode 100644 index 0000000..503cebd --- /dev/null +++ b/docs/resources/account.md @@ -0,0 +1,45 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "unifi_account Resource - terraform-provider-unifi" +subcategory: "" +description: |- + unifi_account manages a RADIUS user account + To authenticate devices based on MAC address, use the MAC address as the username and password under client creation. + Convert lowercase letters to uppercase, and also remove colons or periods from the MAC address. + ATTENTION: If the user profile does not include a VLAN, the client will fall back to the untagged VLAN. + NOTE: MAC-based authentication accounts can only be used for wireless and wired clients. L2TP remote access does not apply. +--- + +# unifi_account (Resource) + +`unifi_account` manages a RADIUS user account + +To authenticate devices based on MAC address, use the MAC address as the username and password under client creation. +Convert lowercase letters to uppercase, and also remove colons or periods from the MAC address. + +ATTENTION: If the user profile does not include a VLAN, the client will fall back to the untagged VLAN. + +NOTE: MAC-based authentication accounts can only be used for wireless and wired clients. L2TP remote access does not apply. + + + + +## Schema + +### Required + +- `name` (String) The name of the account. +- `password` (String, Sensitive) The password of the account. + +### Optional + +- `network_id` (String) ID of the network for this account +- `site` (String) The name of the site to associate the account with. +- `tunnel_medium_type` (Number) See [RFC 2868](https://www.rfc-editor.org/rfc/rfc2868) section 3.2 Defaults to `6`. +- `tunnel_type` (Number) See [RFC 2868](https://www.rfc-editor.org/rfc/rfc2868) section 3.1 Defaults to `13`. + +### Read-Only + +- `id` (String) The ID of the account. + + diff --git a/docs/resources/radius_profile.md b/docs/resources/radius_profile.md index 164bbca..3e1b6bd 100644 --- a/docs/resources/radius_profile.md +++ b/docs/resources/radius_profile.md @@ -3,12 +3,12 @@ page_title: "unifi_radius_profile Resource - terraform-provider-unifi" subcategory: "" description: |- - unifi_radius_profile manages radius profiles. + unifi_radius_profile manages RADIUS profiles. --- # unifi_radius_profile (Resource) -`unifi_radius_profile` manages radius profiles. +`unifi_radius_profile` manages RADIUS profiles. @@ -21,14 +21,14 @@ description: |- ### Optional -- `accounting_enabled` (Boolean) Specifies whether to use radius accounting. Defaults to `false`. +- `accounting_enabled` (Boolean) Specifies whether to use RADIUS accounting. Defaults to `false`. - `acct_server` (Block List) RADIUS accounting servers. (see [below for nested schema](#nestedblock--acct_server)) - `auth_server` (Block List) RADIUS authentication servers. (see [below for nested schema](#nestedblock--auth_server)) - `interim_update_enabled` (Boolean) Specifies whether to use interim_update. Defaults to `false`. - `interim_update_interval` (Number) Specifies interim_update interval. Defaults to `3600`. - `site` (String) The name of the site to associate the settings with. -- `use_usg_acct_server` (Boolean) Specifies whether to use usg as a radius accounting server. Defaults to `false`. -- `use_usg_auth_server` (Boolean) Specifies whether to use usg as a radius authentication server. Defaults to `false`. +- `use_usg_acct_server` (Boolean) Specifies whether to use usg as a RADIUS accounting server. Defaults to `false`. +- `use_usg_auth_server` (Boolean) Specifies whether to use usg as a RADIUS authentication server. Defaults to `false`. - `vlan_enabled` (Boolean) Specifies whether to use vlan on wired connections. Defaults to `false`. - `vlan_wlan_mode` (String) Specifies whether to use vlan on wireless connections. Must be one of `disabled`, `optional`, or `required`. Defaults to ``. diff --git a/internal/provider/data_account.go b/internal/provider/data_account.go new file mode 100644 index 0000000..9211440 --- /dev/null +++ b/internal/provider/data_account.go @@ -0,0 +1,86 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataAccount() *schema.Resource { + return &schema.Resource{ + Description: "`unifi_account` data source can be used to retrieve RADIUS user accounts", + + ReadContext: dataAccountRead, + + Schema: map[string]*schema.Schema{ + "id": { + Description: "The ID of this account.", + Type: schema.TypeString, + Computed: true, + }, + "site": { + Description: "The name of the site the account is associated with.", + Type: schema.TypeString, + Computed: true, + Optional: true, + }, + "name": { + Description: "The name of the account to look up", + Type: schema.TypeString, + Required: true, + }, + + "password": { + Description: "The password of the account.", + Type: schema.TypeString, + Computed: true, + Sensitive: true, + }, + "tunnel_type": { + Description: "See RFC2868 section 3.1", // @TODO: better documentation https://help.ui.com/hc/en-us/articles/360015268353-UniFi-USG-UDM-Configuring-RADIUS-Server#6 + Type: schema.TypeInt, + Computed: true, + }, + "tunnel_medium_type": { + Description: "See RFC2868 section 3.2", // @TODO: better documentation https://help.ui.com/hc/en-us/articles/360015268353-UniFi-USG-UDM-Configuring-RADIUS-Server#6 + Type: schema.TypeInt, + Computed: true, + }, + "network_id": { + Description: "ID of the network for this account", + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func dataAccountRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*client) + + name := d.Get("name").(string) + site := d.Get("site").(string) + if site == "" { + site = c.site + } + + accounts, err := c.c.ListAccounts(ctx, site) + if err != nil { + return diag.FromErr(err) + } + for _, account := range accounts { + if account.Name == name { + d.SetId(account.ID) + d.Set("name", account.Name) + d.Set("password", account.XPassword) + d.Set("tunnel_type", account.TunnelType) + d.Set("tunnel_medium_type", account.TunnelMediumType) + d.Set("network_id", account.NetworkID) + d.Set("site", site) + return nil + } + } + + return diag.Errorf("Account not found with name %s", name) +} diff --git a/internal/provider/data_account_test.go b/internal/provider/data_account_test.go new file mode 100644 index 0000000..065c1bb --- /dev/null +++ b/internal/provider/data_account_test.go @@ -0,0 +1,71 @@ +package provider + +import ( + "fmt" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "testing" +) + +func TestAccDataAccount_default(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + preCheck(t) + }, + ProviderFactories: providerFactories, + // TODO: CheckDestroy: , + Steps: []resource.TestStep{ + { + Config: testAccDataAccountConfig("tfusertest", "secure_1234"), + Check: resource.ComposeTestCheckFunc(), + }, + }, + }) +} + +func TestAccDataAccount_mac(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + preCheck(t) + }, + ProviderFactories: providerFactories, + // TODO: CheckDestroy: , + Steps: []resource.TestStep{ + { + Config: testAccDataMacAccountConfig("00B0D06FC226"), + Check: resource.ComposeTestCheckFunc(), + }, + }, + }) +} + +func testAccDataAccountConfig(name, password string) string { + return fmt.Sprintf(` +resource "unifi_account" "test" { + name = "%s" + password = "%s" +} + +data "unifi_account" "test" { + name = "%s" +depends_on = [ + unifi_account.test + ] +} +`, name, password, name) +} + +func testAccDataMacAccountConfig(mac string) string { + return fmt.Sprintf(` +resource "unifi_account" "test" { + name = "%s" + password = "%s" +} + +data "unifi_account" "test" { + name = "%s" +depends_on = [ + unifi_account.test + ] +} +`, mac, mac, mac) +} diff --git a/internal/provider/data_radius_profile.go b/internal/provider/data_radius_profile.go index de1377e..2943a1d 100644 --- a/internal/provider/data_radius_profile.go +++ b/internal/provider/data_radius_profile.go @@ -20,7 +20,7 @@ func dataRADIUSProfile() *schema.Resource { Computed: true, }, "site": { - Description: "The name of the site the radius profile is associated with.", + Description: "The name of the site the RADIUS profile is associated with.", Type: schema.TypeString, Computed: true, Optional: true, diff --git a/internal/provider/lazy_client.go b/internal/provider/lazy_client.go index aa67acb..d326d9c 100644 --- a/internal/provider/lazy_client.go +++ b/internal/provider/lazy_client.go @@ -369,6 +369,36 @@ func (c *lazyClient) UpdateRADIUSProfile(ctx context.Context, site string, d *un } return c.inner.UpdateRADIUSProfile(ctx, site, d) } +func (c *lazyClient) ListAccounts(ctx context.Context, site string) ([]unifi.Account, error) { + if err := c.init(ctx); err != nil { + return nil, err + } + return c.inner.ListAccount(ctx, site) +} +func (c *lazyClient) GetAccount(ctx context.Context, site, id string) (*unifi.Account, error) { + if err := c.init(ctx); err != nil { + return nil, err + } + return c.inner.GetAccount(ctx, site, id) +} +func (c *lazyClient) DeleteAccount(ctx context.Context, site, id string) error { + if err := c.init(ctx); err != nil { + return err + } + return c.inner.DeleteAccount(ctx, site, id) +} +func (c *lazyClient) CreateAccount(ctx context.Context, site string, d *unifi.Account) (*unifi.Account, error) { + if err := c.init(ctx); err != nil { + return nil, err + } + return c.inner.CreateAccount(ctx, site, d) +} +func (c *lazyClient) UpdateAccount(ctx context.Context, site string, d *unifi.Account) (*unifi.Account, error) { + if err := c.init(ctx); err != nil { + return nil, err + } + return c.inner.UpdateAccount(ctx, site, d) +} func (c *lazyClient) GetSite(ctx context.Context, id string) (*unifi.Site, error) { if err := c.init(ctx); err != nil { return nil, err diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 6ee47c2..7c73dcb 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -75,6 +75,7 @@ func New(version string) func() *schema.Provider { "unifi_radius_profile": dataRADIUSProfile(), "unifi_user_group": dataUserGroup(), "unifi_user": dataUser(), + "unifi_account": dataAccount(), }, ResourcesMap: map[string]*schema.Resource{ // TODO: "unifi_ap_group" @@ -91,6 +92,7 @@ func New(version string) func() *schema.Provider { "unifi_user_group": resourceUserGroup(), "unifi_user": resourceUser(), "unifi_wlan": resourceWLAN(), + "unifi_account": resourceAccount(), "unifi_setting_mgmt": resourceSettingMgmt(), "unifi_setting_radius": resourceSettingRadius(), @@ -187,6 +189,12 @@ type unifiClient interface { CreateRADIUSProfile(ctx context.Context, site string, d *unifi.RADIUSProfile) (*unifi.RADIUSProfile, error) UpdateRADIUSProfile(ctx context.Context, site string, d *unifi.RADIUSProfile) (*unifi.RADIUSProfile, error) + ListAccounts(ctx context.Context, site string) ([]unifi.Account, error) + GetAccount(ctx context.Context, site, id string) (*unifi.Account, error) + DeleteAccount(ctx context.Context, site, id string) error + CreateAccount(ctx context.Context, site string, d *unifi.Account) (*unifi.Account, error) + UpdateAccount(ctx context.Context, site string, d *unifi.Account) (*unifi.Account, error) + GetSite(ctx context.Context, id string) (*unifi.Site, error) ListSites(ctx context.Context) ([]unifi.Site, error) CreateSite(ctx context.Context, Description string) ([]unifi.Site, error) diff --git a/internal/provider/resource_account.go b/internal/provider/resource_account.go new file mode 100644 index 0000000..473769a --- /dev/null +++ b/internal/provider/resource_account.go @@ -0,0 +1,179 @@ +package provider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/paultyng/go-unifi/unifi" +) + +func resourceAccount() *schema.Resource { + return &schema.Resource{ + Description: "`unifi_account` manages a RADIUS user account\n\n" + + "To authenticate devices based on MAC address, use the MAC address as the username and password under client creation. \n" + + "Convert lowercase letters to uppercase, and also remove colons or periods from the MAC address. \n\n" + + "ATTENTION: If the user profile does not include a VLAN, the client will fall back to the untagged VLAN. \n\n" + + "NOTE: MAC-based authentication accounts can only be used for wireless and wired clients. L2TP remote access does not apply.", + + CreateContext: resourceAccountCreate, + ReadContext: resourceAccountRead, + UpdateContext: resourceAccountUpdate, + DeleteContext: resourceAccountDelete, + Importer: &schema.ResourceImporter{ + StateContext: importSiteAndID, + }, + + Schema: map[string]*schema.Schema{ + "id": { + Description: "The ID of the account.", + Type: schema.TypeString, + Computed: true, + }, + "site": { + Description: "The name of the site to associate the account with.", + Type: schema.TypeString, + Computed: true, + Optional: true, + ForceNew: true, + }, + "name": { + Description: "The name of the account.", + Type: schema.TypeString, + Required: true, + }, + "password": { + Description: "The password of the account.", + Type: schema.TypeString, + Required: true, + Sensitive: true, + }, + "tunnel_type": { + Description: "See [RFC 2868](https://www.rfc-editor.org/rfc/rfc2868) section 3.1", // @TODO: better documentation https://help.ui.com/hc/en-us/articles/360015268353-UniFi-USG-UDM-Configuring-RADIUS-Server#6 + Type: schema.TypeInt, + Optional: true, + Default: 13, + ValidateFunc: validation.IntBetween(1, 13), + }, + "tunnel_medium_type": { + Description: "See [RFC 2868](https://www.rfc-editor.org/rfc/rfc2868) section 3.2", // @TODO: better documentation https://help.ui.com/hc/en-us/articles/360015268353-UniFi-USG-UDM-Configuring-RADIUS-Server#6 + Type: schema.TypeInt, + Optional: true, + Default: 6, + ValidateFunc: validation.IntBetween(1, 15), + }, + "network_id": { + Description: "ID of the network for this account", + Type: schema.TypeString, + Optional: true, + }, + }, + } +} + +func resourceAccountCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*client) + + req, err := resourceAccountGetResourceData(d) + if err != nil { + return diag.FromErr(err) + } + + site := d.Get("site").(string) + if site == "" { + site = c.site + } + + resp, err := c.c.CreateAccount(ctx, site, req) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(resp.ID) + + return resourceAccountSetResourceData(resp, d, site) +} + +func resourceAccountUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*client) + + site := d.Get("site").(string) + if site == "" { + site = c.site + } + + req, err := resourceAccountGetResourceData(d) + if err != nil { + return diag.FromErr(err) + } + + req.ID = d.Id() + req.SiteID = site + + resp, err := c.c.UpdateAccount(ctx, site, req) + if err != nil { + return diag.FromErr(err) + } + + return resourceAccountSetResourceData(resp, d, site) +} + +func resourceAccountDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*client) + + //name := d.Get("name").(string) + site := d.Get("site").(string) + if site == "" { + site = c.site + } + + id := d.Id() + err := c.c.DeleteAccount(ctx, site, id) + if _, ok := err.(*unifi.NotFoundError); ok { + return nil + } + return diag.FromErr(err) +} + +func resourceAccountRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + c := meta.(*client) + + id := d.Id() + + site := d.Get("site").(string) + if site == "" { + site = c.site + } + + resp, err := c.c.GetAccount(ctx, site, id) + if _, ok := err.(*unifi.NotFoundError); ok { + d.SetId("") + return nil + } + if err != nil { + return diag.FromErr(err) + } + + return resourceAccountSetResourceData(resp, d, site) +} + +func resourceAccountSetResourceData(resp *unifi.Account, d *schema.ResourceData, site string) diag.Diagnostics { + d.Set("site", site) + d.Set("name", resp.Name) + d.Set("password", resp.XPassword) + d.Set("tunnel_type", resp.TunnelType) + d.Set("tunnel_medium_type", resp.TunnelMediumType) + d.Set("network_id", resp.NetworkID) + return nil +} + +func resourceAccountGetResourceData(d *schema.ResourceData) (*unifi.Account, error) { + return &unifi.Account{ + Name: d.Get("name").(string), + XPassword: d.Get("password").(string), + TunnelType: d.Get("tunnel_type").(int), + TunnelMediumType: d.Get("tunnel_medium_type").(int), + NetworkID: d.Get("network_id").(string), + }, nil +} diff --git a/internal/provider/resource_account_test.go b/internal/provider/resource_account_test.go new file mode 100644 index 0000000..d3b5494 --- /dev/null +++ b/internal/provider/resource_account_test.go @@ -0,0 +1,54 @@ +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccAccount_basic(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { preCheck(t) }, + ProviderFactories: providerFactories, + // TODO: CheckDestroy: , + Steps: []resource.TestStep{ + { + Config: testAccAccountConfig("tfacc", "secure"), + Check: resource.ComposeTestCheckFunc( + // testCheckNetworkExists(t, "name"), + resource.TestCheckResourceAttr("unifi_account.test", "name", "tfacc"), + ), + }, + importStep("unifi_account.test"), + }, + }) +} + +func TestAccAccount_mac(t *testing.T) { + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { preCheck(t) }, + ProviderFactories: providerFactories, + // TODO: CheckDestroy: , + Steps: []resource.TestStep{ + { + Config: testAccAccountConfig("00B0D06FC226", "00B0D06FC226"), + Check: resource.ComposeTestCheckFunc( + // testCheckNetworkExists(t, "name"), + resource.TestCheckResourceAttr("unifi_account.test", "name", "00B0D06FC226"), + resource.TestCheckResourceAttr("unifi_account.test", "password", "00B0D06FC226"), + ), + }, + importStep("unifi_account.test"), + }, + }) +} + +func testAccAccountConfig(name, password string) string { + return fmt.Sprintf(` +resource "unifi_account" "test" { + name = "%s" + password = "%s" +} +`, name, password) +} diff --git a/internal/provider/resource_radius_profile.go b/internal/provider/resource_radius_profile.go index 1b23129..973434c 100644 --- a/internal/provider/resource_radius_profile.go +++ b/internal/provider/resource_radius_profile.go @@ -13,7 +13,7 @@ import ( func resourceRadiusProfile() *schema.Resource { return &schema.Resource{ - Description: "`unifi_radius_profile` manages radius profiles.", + Description: "`unifi_radius_profile` manages RADIUS profiles.", CreateContext: resourceRadiusProfileCreate, ReadContext: resourceRadiusProfileRead, @@ -42,7 +42,7 @@ func resourceRadiusProfile() *schema.Resource { Required: true, }, "accounting_enabled": { - Description: "Specifies whether to use radius accounting.", + Description: "Specifies whether to use RADIUS accounting.", Type: schema.TypeBool, Default: false, Optional: true, @@ -60,13 +60,13 @@ func resourceRadiusProfile() *schema.Resource { Optional: true, }, "use_usg_acct_server": { - Description: "Specifies whether to use usg as a radius accounting server.", + Description: "Specifies whether to use usg as a RADIUS accounting server.", Type: schema.TypeBool, Default: false, Optional: true, }, "use_usg_auth_server": { - Description: "Specifies whether to use usg as a radius authentication server.", + Description: "Specifies whether to use usg as a RADIUS authentication server.", Type: schema.TypeBool, Default: false, Optional: true, @@ -405,12 +405,12 @@ func getRadiusProfileIDByName(ctx context.Context, client unifiClient, profileNa continue } if idMatchingName != "" { - return "", fmt.Errorf("Found multiple radius profiles with name '%s'", profileName) + return "", fmt.Errorf("Found multiple RADIUS profiles with name '%s'", profileName) } idMatchingName = profile.ID } if idMatchingName == "" { - return "", fmt.Errorf("Found no radius profile with name '%s', found: %s", profileName, strings.Join(allNames, ", ")) + return "", fmt.Errorf("Found no RADIUS profile with name '%s', found: %s", profileName, strings.Join(allNames, ", ")) } return idMatchingName, nil }