diff --git a/README.md b/README.md index 0d6634d..e832110 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ Terraform provider for managing SplitSecure SAML2 resources via service-account - **`splitsecure_saml2_identity_provider`** — SAML IdP on a SplitSecure team. Computed `metadata_xml` is suitable as the `saml_metadata_document` input for `aws_iam_saml_provider`. - **`splitsecure_saml2_service_provider`** — SAML SP bound to an IdP, supporting all 17 saml2v2 integration variants (AWS, Cloudflare, Okta, GCP, etc.). +- **`splitsecure_grant`** — per-resource permission grant `(resource, grantee) → view/use/edit` so the resources above are visible/usable to org members, not just their creator. Plain RPCs, not proposal-gated. Tiers control visibility and permission management; resource mutations stay proposal/voter-gated regardless of tier. +- **`splitsecure_group`** — locally-managed principal group with an authoritative member list, used as a grant target. Group mutations require the provider's service account to hold the org **admin** role; grants alone work with the default member role (the creating service account receives `edit` on resources it creates). + +Data sources: **`splitsecure_organization`** (org info incl. the "Everyone" group S2R for org-wide grants), **`splitsecure_principal`** (principal S2R by console email — resolves both users and service accounts), **`splitsecure_org_member`** (user S2R by email), **`splitsecure_group`** (existing group by its `group_s2r`). Generated reference docs live in [`docs/`](./docs); per-resource attribute tables, validators, and example blocks are kept in sync via `tfplugindocs` (`make docs`). diff --git a/docs/data-sources/group.md b/docs/data-sources/group.md new file mode 100644 index 0000000..ba1b3dd --- /dev/null +++ b/docs/data-sources/group.md @@ -0,0 +1,36 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "splitsecure_group Data Source - SplitSecure" +subcategory: "" +description: |- + Resolves an org group by its stable group_s2r, exposing its current name and source. Lookup is by group_s2r only: a group's name is mutable and not unique server-side, so it is not a stable key. The system Everyone group is not a regular group; read everyone_group_s2r from the splitsecure_organization data source instead. +--- + +# splitsecure_group (Data Source) + +Resolves an org group by its stable group_s2r, exposing its current name and source. Lookup is by group_s2r only: a group's name is mutable and not unique server-side, so it is not a stable key. The system Everyone group is not a regular group; read everyone_group_s2r from the splitsecure_organization data source instead. + +## Example Usage + +```terraform +# Resolve a group by its stable group_s2r, exposing its current name and +# source (e.g. to assert its source before granting on it). A group's +# name is mutable and not unique server-side, so group_s2r is the only +# lookup key. To use a group as a grant grantee you can also reference +# its s2r directly, without this data source. +data "splitsecure_group" "sre" { + group_s2r = "s2r:us:group:01HX.../01HY..." +} +``` + + +## Schema + +### Required + +- `group_s2r` (String) Group s2r URI to resolve. Also usable directly as a grant grantee. + +### Read-Only + +- `name` (String) Current group name. Mutable server-side, so do not treat it as an identifier. +- `source` (String) Where the group is managed: "local", "scim", or "system". diff --git a/docs/data-sources/org_member.md b/docs/data-sources/org_member.md new file mode 100644 index 0000000..5ad8cb9 --- /dev/null +++ b/docs/data-sources/org_member.md @@ -0,0 +1,34 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "splitsecure_org_member Data Source - SplitSecure" +subcategory: "" +description: |- + Looks up a single org member by email (case-insensitive). Errors if no member or more than one member matches. +--- + +# splitsecure_org_member (Data Source) + +Looks up a single org member by email (case-insensitive). Errors if no member or more than one member matches. + +## Example Usage + +```terraform +# Resolve an org member by email to the user S2R used as a grant or +# group-member target. Errors if the email matches zero or multiple +# members. +data "splitsecure_org_member" "alice" { + email = "alice@example.com" +} +``` + + +## Schema + +### Required + +- `email` (String) Email address of the member to look up. Matched case-insensitively. + +### Read-Only + +- `display_name` (String) Human-readable display name of the member. +- `user_s2r` (String) User s2r URI of the member. Usable as a grant grantee or a group member principal. diff --git a/docs/data-sources/organization.md b/docs/data-sources/organization.md new file mode 100644 index 0000000..b6cb823 --- /dev/null +++ b/docs/data-sources/organization.md @@ -0,0 +1,29 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "splitsecure_organization Data Source - SplitSecure" +subcategory: "" +description: |- + The organization the provider is configured against. Takes no arguments; the org comes from the provider's org_s2r. +--- + +# splitsecure_organization (Data Source) + +The organization the provider is configured against. Takes no arguments; the org comes from the provider's org_s2r. + +## Example Usage + +```terraform +# The provider-configured org. everyone_group_s2r is the grantee for +# org-wide grants; the "Everyone" group is a system group and is not +# returned by group listings. +data "splitsecure_organization" "current" {} +``` + + +## Schema + +### Read-Only + +- `everyone_group_s2r` (String) S2R of the org's system Everyone group -- the grantee to use for org-wide grants. System groups are NOT returned by group listings (including the splitsecure_group data source); this attribute is the way to obtain it. +- `id` (String) Org s2r URI. +- `name` (String) Human-readable organization name. diff --git a/docs/data-sources/principal.md b/docs/data-sources/principal.md new file mode 100644 index 0000000..b3e0dd1 --- /dev/null +++ b/docs/data-sources/principal.md @@ -0,0 +1,41 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "splitsecure_principal Data Source - SplitSecure" +subcategory: "" +description: |- + Resolves an org principal (user or service account) to its s2r by the email shown in the console. Use the s2r as a group member or grant grantee. +--- + +# splitsecure_principal (Data Source) + +Resolves an org principal (user or service account) to its s2r by the email shown in the console. Use the s2r as a group member or grant grantee. + +## Example Usage + +```terraform +# Resolve an org principal (user or service account) to its s2r by the email +# shown in the console. Use the s2r as a group member or grant grantee. + +# A human user. +data "splitsecure_principal" "alice" { + email = "alice@example.com" +} + +# A service account (email is the one displayed in the console). +data "splitsecure_principal" "ci_bot" { + email = "kQ7...@abc123.serviceaccount.us.splitsecure.com" +} +``` + + +## Schema + +### Required + +- `email` (String) Email of the principal, copy-pasted from the console. Matched case-insensitively. + +### Read-Only + +- `display_name` (String) Display name (user) or name (service account). +- `kind` (String) Principal kind: "user" or "service_account". +- `s2r` (String) Principal s2r URI (usr: for users, sa: for service accounts). Usable as a group member or grant grantee. diff --git a/docs/index.md b/docs/index.md index f594420..4a6492f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,7 +39,25 @@ provider "splitsecure" { ## Full Example -End-to-end wiring: a SplitSecure team with a SAML2 IdP, mirrored as `aws_iam_saml_provider` on the AWS side, with admin and readonly IAM roles and a single SP allowing federation into both. Lives at [`examples/full/main.tf`](https://github.com/splitsecure/terraform-provider-splitsecure/tree/main/examples/full) in the repo. +End-to-end wiring: a SplitSecure team with a SAML2 IdP, mirrored as `aws_iam_saml_provider` on the AWS side, with admin and readonly IAM roles and a single SP allowing federation into both. Lives at [`examples/full`](https://github.com/splitsecure/terraform-provider-splitsecure/tree/main/examples/full) in the repo. + +```terraform +variable "org_s2r" { + type = string + description = "Org s2r URI hosting the team below. Used by the provider to spawn the proposal-scoped managed enclave on every Create / Delete." +} + +variable "team_s2r" { + type = string + description = "Team s2r URI that owns the IdP and SP. Voters on this team approve every Create / Delete proposal." +} + +variable "operator_emails" { + type = list(string) + default = [] + description = "Emails (as shown in the console) of users / service accounts allowed to operate the AWS federation SP." +} +``` ```terraform terraform { @@ -60,16 +78,6 @@ provider "splitsecure" { provider "aws" {} -variable "org_s2r" { - type = string - description = "Org s2r URI hosting the team below. Used by the provider to spawn the proposal-scoped managed enclave on every Create / Delete." -} - -variable "team_s2r" { - type = string - description = "Team s2r URI that owns the IdP and SP. Voters on this team approve every Create / Delete proposal." -} - data "aws_caller_identity" "current" {} locals { @@ -203,4 +211,34 @@ output "aws_readonly_role_arn" { value = aws_iam_role.readonly.arn description = "ARN of the readonly role users assume via SAML." } + +# --- Access ------------------------------------------------------- +# Terraform-managed permissions on the SP. Without grants, only org +# owners/admins (and the creating service account) can see it. + +data "splitsecure_organization" "current" {} + +# Resolve each console email to its principal s2r (users and service +# accounts alike), so callers paste emails rather than raw s2rs. +data "splitsecure_principal" "operators" { + for_each = toset(var.operator_emails) + email = each.value +} + +resource "splitsecure_group" "operators" { + name = "aws-federation-operators-${local.account_id}" + members = [for p in data.splitsecure_principal.operators : p.s2r] +} + +resource "splitsecure_grant" "operators_use" { + resource_s2r = splitsecure_saml2_service_provider.main.id + grantee_s2r = splitsecure_group.operators.group_s2r + tier = "use" +} + +resource "splitsecure_grant" "org_view" { + resource_s2r = splitsecure_saml2_service_provider.main.id + grantee_s2r = data.splitsecure_organization.current.everyone_group_s2r + tier = "view" +} ``` diff --git a/docs/resources/grant.md b/docs/resources/grant.md new file mode 100644 index 0000000..63a17be --- /dev/null +++ b/docs/resources/grant.md @@ -0,0 +1,46 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "splitsecure_grant Resource - SplitSecure" +subcategory: "" +description: |- + Permission grant giving a principal (user, service account, or group) a tier on a resource. Keyed by (resource, grantee) within the provider-configured org; only the tier can change in place. +--- + +# splitsecure_grant (Resource) + +Permission grant giving a principal (user, service account, or group) a tier on a resource. Keyed by (resource, grantee) within the provider-configured org; only the tier can change in place. + +## Example Usage + +```terraform +# Grants authorize a principal (user, service account, or group) to act +# on one resource at a tier: view < use < edit. PutGrant semantics are +# upsert, so changing tier updates the grant in place; changing the +# resource or grantee replaces it. +# +# Org owners and admins hold the edit tier on every resource implicitly; +# grants matter for plain members. + +# Grant a group access to a SAML2 service provider. +resource "splitsecure_grant" "sre_use" { + resource_s2r = splitsecure_saml2_service_provider.main.id + grantee_s2r = data.splitsecure_group.sre.group_s2r + tier = "use" +} + +# Grant every org member visibility via the system "Everyone" group. +resource "splitsecure_grant" "org_view" { + resource_s2r = splitsecure_saml2_service_provider.main.id + grantee_s2r = data.splitsecure_organization.current.everyone_group_s2r + tier = "view" +} +``` + + +## Schema + +### Required + +- `grantee_s2r` (String) s2r URI of the principal receiving access: a user, service account, or group. +- `resource_s2r` (String) s2r URI of the resource being shared. +- `tier` (String) Access tier. One of: view, use, edit. diff --git a/docs/resources/group.md b/docs/resources/group.md new file mode 100644 index 0000000..961061d --- /dev/null +++ b/docs/resources/group.md @@ -0,0 +1,51 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "splitsecure_group Resource - SplitSecure" +subcategory: "" +description: |- + Locally-managed principal group in the provider-configured organization. Terraform is authoritative over the member set; SCIM- and system-managed groups cannot be managed by this resource. +--- + +# splitsecure_group (Resource) + +Locally-managed principal group in the provider-configured organization. Terraform is authoritative over the member set; SCIM- and system-managed groups cannot be managed by this resource. + +## Example Usage + +```terraform +# Groups are named sets of principals used as grant targets. Terraform +# manages locally-sourced groups only: SCIM groups are owned by the +# IdP and the system "Everyone" group is implicit. The members list is +# authoritative — principals added out of band are removed on the next +# apply. +# +# Group mutations require the service account behind the provider to +# hold the org admin role. +# +# Members are referenced by the email shown in the console; the +# splitsecure_principal data source resolves each to its s2r (users and +# service accounts alike). +resource "splitsecure_group" "sre" { + name = "SRE" + members = [ + data.splitsecure_principal.alice.s2r, # a user + data.splitsecure_principal.ci_bot.s2r, # a service account + ] +} +``` + + +## Schema + +### Required + +- `name` (String) Human-readable group name. Changing it updates the group in place. + +### Optional + +- `members` (Set of String) Authoritative set of member principal s2r URIs (users or service accounts). Principals not listed here are removed on apply. Leave unset for an empty group. + +### Read-Only + +- `group_s2r` (String) Group s2r URI. Stable identifier; also the import ID. +- `source` (String) Where the group is managed from: "local", "scim", or "system". Always "local" for Terraform-managed groups. diff --git a/examples/data-sources/splitsecure_group/data-source.tf b/examples/data-sources/splitsecure_group/data-source.tf new file mode 100644 index 0000000..da6a774 --- /dev/null +++ b/examples/data-sources/splitsecure_group/data-source.tf @@ -0,0 +1,8 @@ +# Resolve a group by its stable group_s2r, exposing its current name and +# source (e.g. to assert its source before granting on it). A group's +# name is mutable and not unique server-side, so group_s2r is the only +# lookup key. To use a group as a grant grantee you can also reference +# its s2r directly, without this data source. +data "splitsecure_group" "sre" { + group_s2r = "s2r:us:group:01HX.../01HY..." +} diff --git a/examples/data-sources/splitsecure_org_member/data-source.tf b/examples/data-sources/splitsecure_org_member/data-source.tf new file mode 100644 index 0000000..184dffe --- /dev/null +++ b/examples/data-sources/splitsecure_org_member/data-source.tf @@ -0,0 +1,6 @@ +# Resolve an org member by email to the user S2R used as a grant or +# group-member target. Errors if the email matches zero or multiple +# members. +data "splitsecure_org_member" "alice" { + email = "alice@example.com" +} diff --git a/examples/data-sources/splitsecure_organization/data-source.tf b/examples/data-sources/splitsecure_organization/data-source.tf new file mode 100644 index 0000000..388884f --- /dev/null +++ b/examples/data-sources/splitsecure_organization/data-source.tf @@ -0,0 +1,4 @@ +# The provider-configured org. everyone_group_s2r is the grantee for +# org-wide grants; the "Everyone" group is a system group and is not +# returned by group listings. +data "splitsecure_organization" "current" {} diff --git a/examples/data-sources/splitsecure_principal/data-source.tf b/examples/data-sources/splitsecure_principal/data-source.tf new file mode 100644 index 0000000..62b317e --- /dev/null +++ b/examples/data-sources/splitsecure_principal/data-source.tf @@ -0,0 +1,12 @@ +# Resolve an org principal (user or service account) to its s2r by the email +# shown in the console. Use the s2r as a group member or grant grantee. + +# A human user. +data "splitsecure_principal" "alice" { + email = "alice@example.com" +} + +# A service account (email is the one displayed in the console). +data "splitsecure_principal" "ci_bot" { + email = "kQ7...@abc123.serviceaccount.us.splitsecure.com" +} diff --git a/examples/full/main.tf b/examples/full/main.tf index 2c1087b..7178bc5 100644 --- a/examples/full/main.tf +++ b/examples/full/main.tf @@ -16,16 +16,6 @@ provider "splitsecure" { provider "aws" {} -variable "org_s2r" { - type = string - description = "Org s2r URI hosting the team below. Used by the provider to spawn the proposal-scoped managed enclave on every Create / Delete." -} - -variable "team_s2r" { - type = string - description = "Team s2r URI that owns the IdP and SP. Voters on this team approve every Create / Delete proposal." -} - data "aws_caller_identity" "current" {} locals { @@ -159,3 +149,33 @@ output "aws_readonly_role_arn" { value = aws_iam_role.readonly.arn description = "ARN of the readonly role users assume via SAML." } + +# --- Access ------------------------------------------------------- +# Terraform-managed permissions on the SP. Without grants, only org +# owners/admins (and the creating service account) can see it. + +data "splitsecure_organization" "current" {} + +# Resolve each console email to its principal s2r (users and service +# accounts alike), so callers paste emails rather than raw s2rs. +data "splitsecure_principal" "operators" { + for_each = toset(var.operator_emails) + email = each.value +} + +resource "splitsecure_group" "operators" { + name = "aws-federation-operators-${local.account_id}" + members = [for p in data.splitsecure_principal.operators : p.s2r] +} + +resource "splitsecure_grant" "operators_use" { + resource_s2r = splitsecure_saml2_service_provider.main.id + grantee_s2r = splitsecure_group.operators.group_s2r + tier = "use" +} + +resource "splitsecure_grant" "org_view" { + resource_s2r = splitsecure_saml2_service_provider.main.id + grantee_s2r = data.splitsecure_organization.current.everyone_group_s2r + tier = "view" +} diff --git a/examples/full/terraform.tfvars.example b/examples/full/terraform.tfvars.example index 39d1dee..37b0cfa 100644 --- a/examples/full/terraform.tfvars.example +++ b/examples/full/terraform.tfvars.example @@ -1,4 +1,10 @@ -# Copy to terraform.tfvars (gitignored) and fill in the s2r URIs. +# Copy to terraform.tfvars (gitignored) and fill in the values. org_s2r = "s2r:us:org:..." team_s2r = "s2r:us:team:..." + +# Console emails of the users / service accounts that may operate the SP. +operator_emails = [ + "alice@example.com", + "ci-bot@abc123.serviceaccount.us.splitsecure.com", +] diff --git a/examples/full/variables.tf b/examples/full/variables.tf new file mode 100644 index 0000000..80e1bf4 --- /dev/null +++ b/examples/full/variables.tf @@ -0,0 +1,15 @@ +variable "org_s2r" { + type = string + description = "Org s2r URI hosting the team below. Used by the provider to spawn the proposal-scoped managed enclave on every Create / Delete." +} + +variable "team_s2r" { + type = string + description = "Team s2r URI that owns the IdP and SP. Voters on this team approve every Create / Delete proposal." +} + +variable "operator_emails" { + type = list(string) + default = [] + description = "Emails (as shown in the console) of users / service accounts allowed to operate the AWS federation SP." +} diff --git a/examples/resources/splitsecure_grant/resource.tf b/examples/resources/splitsecure_grant/resource.tf new file mode 100644 index 0000000..6b079fe --- /dev/null +++ b/examples/resources/splitsecure_grant/resource.tf @@ -0,0 +1,21 @@ +# Grants authorize a principal (user, service account, or group) to act +# on one resource at a tier: view < use < edit. PutGrant semantics are +# upsert, so changing tier updates the grant in place; changing the +# resource or grantee replaces it. +# +# Org owners and admins hold the edit tier on every resource implicitly; +# grants matter for plain members. + +# Grant a group access to a SAML2 service provider. +resource "splitsecure_grant" "sre_use" { + resource_s2r = splitsecure_saml2_service_provider.main.id + grantee_s2r = data.splitsecure_group.sre.group_s2r + tier = "use" +} + +# Grant every org member visibility via the system "Everyone" group. +resource "splitsecure_grant" "org_view" { + resource_s2r = splitsecure_saml2_service_provider.main.id + grantee_s2r = data.splitsecure_organization.current.everyone_group_s2r + tier = "view" +} diff --git a/examples/resources/splitsecure_group/resource.tf b/examples/resources/splitsecure_group/resource.tf new file mode 100644 index 0000000..ad25a0c --- /dev/null +++ b/examples/resources/splitsecure_group/resource.tf @@ -0,0 +1,19 @@ +# Groups are named sets of principals used as grant targets. Terraform +# manages locally-sourced groups only: SCIM groups are owned by the +# IdP and the system "Everyone" group is implicit. The members list is +# authoritative — principals added out of band are removed on the next +# apply. +# +# Group mutations require the service account behind the provider to +# hold the org admin role. +# +# Members are referenced by the email shown in the console; the +# splitsecure_principal data source resolves each to its s2r (users and +# service accounts alike). +resource "splitsecure_group" "sre" { + name = "SRE" + members = [ + data.splitsecure_principal.alice.s2r, # a user + data.splitsecure_principal.ci_bot.s2r, # a service account + ] +} diff --git a/go.mod b/go.mod index 4660cbc..20360d0 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 github.com/hashicorp/terraform-plugin-go v0.31.0 github.com/hashicorp/terraform-plugin-log v0.10.0 - github.com/splitsecure/apis v0.0.0-20260514001223-f52c82fb5959 + github.com/splitsecure/apis v0.0.0-20260717200245-1efd2ff90e84 google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index 5812e3a..fd97524 100644 --- a/go.sum +++ b/go.sum @@ -168,8 +168,8 @@ github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQ github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= -github.com/splitsecure/apis v0.0.0-20260514001223-f52c82fb5959 h1:qKSnoDvD7Dc6AKBIKjHrLtGyB6aeOu84VUqlCJnFq/E= -github.com/splitsecure/apis v0.0.0-20260514001223-f52c82fb5959/go.mod h1:wUlW3JTT8bCwxhF+EvEazUtdofaoPjAFRCvYWh49sr4= +github.com/splitsecure/apis v0.0.0-20260717200245-1efd2ff90e84 h1:q0XPABgIhkvC8UjVI7eSH5+vZ5WLOKocknPkeh2yC+o= +github.com/splitsecure/apis v0.0.0-20260717200245-1efd2ff90e84/go.mod h1:cwf2NxvyDjqjzNkwvQts1gp2i+CuyGXoZCN+d1+Hvuw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/splitsecure/client/client.go b/splitsecure/client/client.go index 8f43d0f..c07c232 100644 --- a/splitsecure/client/client.go +++ b/splitsecure/client/client.go @@ -10,6 +10,7 @@ import ( conveniencestorev1connect "github.com/splitsecure/apis/gen/go/proto/splitsecure/conveniencestore/v1/conveniencestorev1connect" enclaveroundtripv1connect "github.com/splitsecure/apis/gen/go/proto/splitsecure/enclaveroundtrip/v1/enclaveroundtripv1connect" + orgsvcv1connect "github.com/splitsecure/apis/gen/go/proto/splitsecure/orgsvc/v1/orgsvcv1connect" proposalsv1connect "github.com/splitsecure/apis/gen/go/proto/splitsecure/proposals/v1/proposalsv1connect" ) @@ -23,6 +24,7 @@ type Client struct { ConvenienceStoreService conveniencestorev1connect.ConvenienceStoreServiceClient EnclaveRoundtripService enclaveroundtripv1connect.EnclaveRoundtripServiceClient ProposalsService proposalsv1connect.ProposalsServiceClient + OrgService orgsvcv1connect.OrgServiceClient OrgS2R string } @@ -32,22 +34,34 @@ type Client struct { // individual RPC calls see unary polls but the wrapper keeps the // connection pool happy across retries. func New(endpoint, bearerToken, orgS2R, version string) *Client { + userAgent := "terraform-provider-splitsecure/" + version + withAuth := func(wrapped http.RoundTripper) http.RoundTripper { + return &loggingTransport{token: bearerToken, userAgent: userAgent, wrapped: wrapped} + } + + // Retrying client for the long-running enclave / proposal flow. retryClient := retryablehttp.NewClient() retryClient.RetryMax = 3 retryClient.Logger = nil - httpClient := retryClient.StandardClient() httpClient.Timeout = 1 * time.Minute - httpClient.Transport = &loggingTransport{ - token: bearerToken, - userAgent: "terraform-provider-splitsecure/" + version, - wrapped: httpClient.Transport, + httpClient.Transport = withAuth(httpClient.Transport) + + // OrgService carries non-idempotent mutations (CreateGroup / UpdateGroup / + // DeleteGroup / PutGrant / DeleteGrant). Every Connect RPC is a POST, so + // retryablehttp can't scope retries by method and would replay a committed + // write if the response is lost. Give it a non-retrying client; resource + // create paths that need it do their own bounded, condition-scoped retries. + orgHTTPClient := &http.Client{ + Timeout: 1 * time.Minute, + Transport: withAuth(http.DefaultTransport), } return &Client{ ConvenienceStoreService: conveniencestorev1connect.NewConvenienceStoreServiceClient(httpClient, endpoint), EnclaveRoundtripService: enclaveroundtripv1connect.NewEnclaveRoundtripServiceClient(httpClient, endpoint), ProposalsService: proposalsv1connect.NewProposalsServiceClient(httpClient, endpoint), + OrgService: orgsvcv1connect.NewOrgServiceClient(orgHTTPClient, endpoint), OrgS2R: orgS2R, } } diff --git a/splitsecure/provider/provider.go b/splitsecure/provider/provider.go index c823f90..43bdbc5 100644 --- a/splitsecure/provider/provider.go +++ b/splitsecure/provider/provider.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/client" + "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/services/org" "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/services/saml2" ) @@ -109,9 +110,16 @@ func (p *splitsecureProvider) Resources(_ context.Context) []func() resource.Res return []func() resource.Resource{ saml2.NewIdentityProvider, saml2.NewServiceProvider, + org.NewGrant, + org.NewGroup, } } func (p *splitsecureProvider) DataSources(_ context.Context) []func() datasource.DataSource { - return nil + return []func() datasource.DataSource{ + org.NewOrganizationDataSource, + org.NewMemberDataSource, + org.NewPrincipalDataSource, + org.NewGroupDataSource, + } } diff --git a/splitsecure/services/org/configure.go b/splitsecure/services/org/configure.go new file mode 100644 index 0000000..8d406c4 --- /dev/null +++ b/splitsecure/services/org/configure.go @@ -0,0 +1,30 @@ +package org + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/diag" + + "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/client" +) + +// clientFromProviderData extracts the shared *client.Client the provider +// stashes in ProviderData. It returns nil when ProviderData is unset — +// the framework calls Configure with nil before the provider is +// configured — and records a diagnostic on a type mismatch. +func clientFromProviderData(providerData any, diags *diag.Diagnostics) *client.Client { + if providerData == nil { + return nil + } + c, ok := providerData.(*client.Client) + if !ok { + diags.AddError( + "Unexpected provider data type", + fmt.Sprintf("expected *client.Client, got %T", providerData), + ) + + return nil + } + + return c +} diff --git a/splitsecure/services/org/configure_internal_test.go b/splitsecure/services/org/configure_internal_test.go new file mode 100644 index 0000000..c7c08c8 --- /dev/null +++ b/splitsecure/services/org/configure_internal_test.go @@ -0,0 +1,32 @@ +package org + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + + "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/client" +) + +func TestClientFromProviderData(t *testing.T) { + t.Parallel() + + // nil ProviderData (the framework's early Configure call) -> nil, no error. + var d diag.Diagnostics + if got := clientFromProviderData(nil, &d); got != nil || d.HasError() { + t.Fatalf("nil provider data: got %v, diags %v", got, d) + } + + // Wrong type -> nil and a diagnostic. + d = diag.Diagnostics{} + if got := clientFromProviderData("not a client", &d); got != nil || !d.HasError() { + t.Fatalf("wrong type: got %v, diags %v", got, d) + } + + // Correct type -> returned unchanged, no error. + d = diag.Diagnostics{} + c := &client.Client{OrgS2R: "s2r:test:org:x"} + if got := clientFromProviderData(c, &d); got != c || d.HasError() { + t.Fatalf("correct type: got %v, diags %v", got, d) + } +} diff --git a/splitsecure/services/org/data_internal_test.go b/splitsecure/services/org/data_internal_test.go new file mode 100644 index 0000000..bab5e66 --- /dev/null +++ b/splitsecure/services/org/data_internal_test.go @@ -0,0 +1,98 @@ +package org + +import ( + "strings" + "testing" + + orgsvcv1 "github.com/splitsecure/apis/gen/go/proto/splitsecure/orgsvc/v1" +) + +func TestSingleMember(t *testing.T) { + t.Parallel() + + // The server resolves emails to members (canonicalization, ambiguity); the + // client only extracts the single member from the per-email Result. Matching + // is by the request email string the server echoes back. + oneResult := func(email string, members ...*orgsvcv1.Member) []*orgsvcv1.GetMembersByEmailResponse_Result { + return []*orgsvcv1.GetMembersByEmailResponse_Result{{Email: email, Members: members}} + } + alice := &orgsvcv1.Member{UserId: "s2r:us:usr:alice", Email: "alice@example.com", DisplayName: "Alice"} + carol1 := &orgsvcv1.Member{UserId: "s2r:us:usr:carol1", Email: "carol@example.com", DisplayName: "Carol One"} + carol2 := &orgsvcv1.Member{UserId: "s2r:us:usr:carol2", Email: "carol@example.com", DisplayName: "Carol Two"} + + cases := []struct { + name string + email string + results []*orgsvcv1.GetMembersByEmailResponse_Result + wantUserID string + wantErrPart string // empty means the lookup must succeed + }{ + {name: "single member resolves", email: "alice@example.com", results: oneResult("alice@example.com", alice), wantUserID: "s2r:us:usr:alice"}, + {name: "ambiguous email returns error listing matches", email: "carol@example.com", results: oneResult("carol@example.com", carol1, carol2), wantErrPart: "s2r:us:usr:carol2"}, + {name: "empty members returns error naming the email", email: "dave@example.com", results: oneResult("dave@example.com"), wantErrPart: "dave@example.com"}, + {name: "no matching result returns error naming the email", email: "erin@example.com", results: oneResult("alice@example.com", alice), wantErrPart: "erin@example.com"}, + {name: "empty results returns error", email: "alice@example.com", results: nil, wantErrPart: "alice@example.com"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := singleMember(tc.results, tc.email) + if tc.wantErrPart != "" { + if err == nil { + t.Fatalf("singleMember(%q) = %+v, want error", tc.email, got) + } + if !strings.Contains(err.Error(), tc.wantErrPart) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantErrPart) + } + + return + } + if err != nil { + t.Fatalf("singleMember(%q): %v", tc.email, err) + } + if got.GetUserId() != tc.wantUserID { + t.Fatalf("got user %q, want %q", got.GetUserId(), tc.wantUserID) + } + }) + } +} + +func TestGroupSourceToString(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + source orgsvcv1.GroupSource + want string + wantError bool + }{ + {name: "local", source: orgsvcv1.GroupSource_GROUP_SOURCE_LOCAL, want: "local"}, + {name: "scim", source: orgsvcv1.GroupSource_GROUP_SOURCE_SCIM, want: "scim"}, + {name: "system", source: orgsvcv1.GroupSource_GROUP_SOURCE_SYSTEM, want: "system"}, + {name: "unspecified returns error", source: orgsvcv1.GroupSource_GROUP_SOURCE_UNSPECIFIED, wantError: true}, + {name: "out-of-range value returns error", source: orgsvcv1.GroupSource(99), wantError: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := groupSourceToString(tc.source) + if tc.wantError { + if err == nil { + t.Fatalf("groupSourceToString(%v) = %q, want error", tc.source, got) + } + + return + } + if err != nil { + t.Fatalf("groupSourceToString(%v): %v", tc.source, err) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} diff --git a/splitsecure/services/org/doc.go b/splitsecure/services/org/doc.go new file mode 100644 index 0000000..1117a06 --- /dev/null +++ b/splitsecure/services/org/doc.go @@ -0,0 +1,5 @@ +// Package org implements org-scoped Terraform resources and data +// sources: per-resource permission grants, principal groups, and +// directory lookups (organization, members, groups). All of them talk +// to OrgService and operate within the provider-configured org. +package org diff --git a/splitsecure/services/org/grant.go b/splitsecure/services/org/grant.go new file mode 100644 index 0000000..613ca0f --- /dev/null +++ b/splitsecure/services/org/grant.go @@ -0,0 +1,335 @@ +package org + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "connectrpc.com/connect" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + authzv1 "github.com/splitsecure/apis/gen/go/proto/splitsecure/authz/v1" + orgsvcv1 "github.com/splitsecure/apis/gen/go/proto/splitsecure/orgsvc/v1" + "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/client" +) + +var ( + _ resource.Resource = (*grantResource)(nil) + _ resource.ResourceWithImportState = (*grantResource)(nil) +) + +type grantResource struct { + client *client.Client +} + +type grantModel struct { + ResourceS2R types.String `tfsdk:"resource_s2r"` + GranteeS2R types.String `tfsdk:"grantee_s2r"` + Tier types.String `tfsdk:"tier"` +} + +// NewGrant returns a factory for the per-resource permission grant +// resource. +func NewGrant() resource.Resource { + return &grantResource{} +} + +func (r *grantResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_grant" +} + +func (r *grantResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = clientFromProviderData(req.ProviderData, &resp.Diagnostics) +} + +func (r *grantResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Permission grant giving a principal (user, service account, or group) a tier on a resource. " + + "Keyed by (resource, grantee) within the provider-configured org; only the tier can change in place.", + Attributes: map[string]schema.Attribute{ + "resource_s2r": schema.StringAttribute{ + Required: true, + Description: "s2r URI of the resource being shared.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "grantee_s2r": schema.StringAttribute{ + Required: true, + Description: "s2r URI of the principal receiving access: a user, service account, or group.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "tier": schema.StringAttribute{ + Required: true, + Description: "Access tier. One of: " + strings.Join(tierValues(), ", ") + ".", + Validators: []validator.String{ + stringvalidator.OneOf(tierValues()...), + }, + }, + }, + } +} + +func (r *grantResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan grantModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.upsertGrant(ctx, plan, true)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *grantResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state grantModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + getResp, err := r.client.OrgService.GetGrant(ctx, connect.NewRequest(&orgsvcv1.GetGrantRequest{ + OrgS2R: r.client.OrgS2R, + ResourceS2R: state.ResourceS2R.ValueString(), + GranteeS2R: state.GranteeS2R.ValueString(), + })) + if err != nil { + if connect.CodeOf(err) == connect.CodeNotFound { + resp.State.RemoveResource(ctx) + + return + } + resp.Diagnostics.AddError("Reading grant", err.Error()) + + return + } + + resp.Diagnostics.Append(populateGrantModel(&state, getResp.Msg.GetGrant())...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *grantResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan grantModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(r.upsertGrant(ctx, plan, false)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *grantResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state grantModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.OrgService.DeleteGrant(ctx, connect.NewRequest(&orgsvcv1.DeleteGrantRequest{ + OrgS2R: r.client.OrgS2R, + ResourceS2R: state.ResourceS2R.ValueString(), + GranteeS2R: state.GranteeS2R.ValueString(), + })) + if err != nil && connect.CodeOf(err) != connect.CodeNotFound { + resp.Diagnostics.AddError("Deleting grant", err.Error()) + } +} + +func (r *grantResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resourceS2R, granteeS2R, err := parseGrantImportID(req.ID) + if err != nil { + resp.Diagnostics.AddError("Invalid import ID", err.Error()) + + return + } + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("resource_s2r"), resourceS2R)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("grantee_s2r"), granteeS2R)...) +} + +// upsertGrant writes the grant via PutGrant (an upsert). retryAuthz +// retries PermissionDenied for ~30s on the create path to absorb the +// creator-grant write race. State is set by the caller from the plan: +// resource_s2r, grantee_s2r, and tier are all config-owned, so the +// server echo is intentionally not read back — writing a server- +// canonicalized value into a RequiresReplace attribute would trip the +// framework's post-apply consistency check. +func (r *grantResource) upsertGrant(ctx context.Context, plan grantModel, retryAuthz bool) diag.Diagnostics { + var d diag.Diagnostics + + tier, err := tierFromString(plan.Tier.ValueString()) + if err != nil { + d.AddError("Invalid tier", err.Error()) + + return d + } + + req := &orgsvcv1.PutGrantRequest{ + OrgS2R: r.client.OrgS2R, + ResourceS2R: plan.ResourceS2R.ValueString(), + GranteeS2R: plan.GranteeS2R.ValueString(), + Tier: tier, + } + + if retryAuthz { + err = r.putGrantRetryingAuthz(ctx, req) + if err != nil { + d.AddError("Creating grant", err.Error()) + } + + return d + } + + _, err = r.client.OrgService.PutGrant(ctx, connect.NewRequest(req)) + if err != nil { + d.AddError("Updating grant", err.Error()) + } + + return d +} + +// putGrantRetryingAuthz is the Create-path PutGrant. It retries +// PermissionDenied for ~30s: a grant against a freshly +// proposal-created resource can race the server-side creator-grant +// write that authorizes this caller. Every other code fails +// immediately. +func (r *grantResource) putGrantRetryingAuthz(ctx context.Context, req *orgsvcv1.PutGrantRequest) error { + waits := []time.Duration{ + 1 * time.Second, + 2 * time.Second, + 4 * time.Second, + 8 * time.Second, + 8 * time.Second, + } + for attempt := 0; ; attempt++ { + _, err := r.client.OrgService.PutGrant(ctx, connect.NewRequest(req)) + if err == nil { + return nil + } + if connect.CodeOf(err) != connect.CodePermissionDenied { + return fmt.Errorf("PutGrant: %w", err) + } + if attempt >= len(waits) { + return fmt.Errorf( + "PutGrant still permission-denied after %d attempts: creating a grant on %s requires the calling principal to hold the %q tier on that resource (an org admin can grant it): %w", + attempt+1, req.GetResourceS2R(), "edit", err, + ) + } + + t := time.NewTimer(waits[attempt]) + select { + case <-ctx.Done(): + t.Stop() + + return ctx.Err() + case <-t.C: + } + } +} + +func populateGrantModel(m *grantModel, g *orgsvcv1.Grant) diag.Diagnostics { + var d diag.Diagnostics + if g == nil { + d.AddError("Decoding grant", "response contains no grant") + + return d + } + tierStr, err := tierToString(g.GetTier()) + if err != nil { + d.AddError("Decoding grant", err.Error()) + + return d + } + + // Refresh only tier. resource_s2r and grantee_s2r are config-owned + // RequiresReplace keys preserved from prior state; echoing the server's + // (possibly canonicalized) value back would trip the framework's post- + // apply consistency check -- same rationale as upsertGrant. + m.Tier = types.StringValue(tierStr) + + return nil +} + +var ( + errUnknownTier = errors.New("unknown tier") + errUnmappableTier = errors.New("unmappable tier enum value") + errMalformedGrantImportID = errors.New("malformed grant import ID") +) + +type tierMapping struct { + name string + tier authzv1.Tier +} + +// tierMappings is the single source of truth for the Terraform-string +// <-> authz enum correspondence; the schema validator, both converters, +// and the docs string all derive from it. +func tierMappings() []tierMapping { + return []tierMapping{ + {name: "view", tier: authzv1.Tier_TIER_VIEW}, + {name: "use", tier: authzv1.Tier_TIER_USE}, + {name: "edit", tier: authzv1.Tier_TIER_EDIT}, + } +} + +func tierValues() []string { + mappings := tierMappings() + names := make([]string, 0, len(mappings)) + for _, m := range mappings { + names = append(names, m.name) + } + + return names +} + +func tierFromString(s string) (authzv1.Tier, error) { + for _, m := range tierMappings() { + if m.name == s { + return m.tier, nil + } + } + + return authzv1.Tier_TIER_UNSPECIFIED, fmt.Errorf("%w: %q (expected one of: %s)", errUnknownTier, s, strings.Join(tierValues(), ", ")) +} + +func tierToString(t authzv1.Tier) (string, error) { + for _, m := range tierMappings() { + if m.tier == t { + return m.name, nil + } + } + + return "", fmt.Errorf("%w: %d (%s)", errUnmappableTier, t, t) +} + +func parseGrantImportID(id string) (string, string, error) { + resourceS2R, granteeS2R, found := strings.Cut(id, ",") + if !found || resourceS2R == "" || granteeS2R == "" || strings.Contains(granteeS2R, ",") { + return "", "", fmt.Errorf("%w: %q (expected \",\", exactly one comma)", errMalformedGrantImportID, id) + } + + return resourceS2R, granteeS2R, nil +} diff --git a/splitsecure/services/org/grant_internal_test.go b/splitsecure/services/org/grant_internal_test.go new file mode 100644 index 0000000..e863531 --- /dev/null +++ b/splitsecure/services/org/grant_internal_test.go @@ -0,0 +1,260 @@ +package org + +import ( + "context" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + authzv1 "github.com/splitsecure/apis/gen/go/proto/splitsecure/authz/v1" +) + +// grantTestSchema pulls the schema out of the grant resource by +// calling its Schema(...) method directly, avoiding framework +// plumbing. +func grantTestSchema(t *testing.T) rschema.Schema { + t.Helper() + + resp := &resource.SchemaResponse{} + (&grantResource{}).Schema(context.Background(), resource.SchemaRequest{}, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("schema diagnostics: %v", resp.Diagnostics) + } + + return resp.Schema +} + +func TestGrantSchema_RequiredAttributes(t *testing.T) { + t.Parallel() + + s := grantTestSchema(t) + + for _, name := range []string{"resource_s2r", "grantee_s2r", "tier"} { + attr, ok := s.Attributes[name] + if !ok { + t.Fatalf("required attribute %q missing from grant schema", name) + } + if !attr.IsRequired() { + t.Errorf("attribute %q should be Required", name) + } + } +} + +// TestGrantSchema_ReplaceSemantics asserts the grant's key attributes +// force replacement while tier stays updatable in place. +func TestGrantSchema_ReplaceSemantics(t *testing.T) { + t.Parallel() + + s := grantTestSchema(t) + + ctx := context.Background() + for _, name := range []string{"resource_s2r", "grantee_s2r"} { + attr, ok := s.Attributes[name].(rschema.StringAttribute) + if !ok { + t.Fatalf("attribute %q has wrong type: %T", name, s.Attributes[name]) + } + if len(attr.PlanModifiers) == 0 { + t.Fatalf("attribute %q has no plan modifiers", name) + } + // Not just any modifier: at least one must carry requires-replace + // semantics (RequiresReplace's description says the resource is recreated). + replaces := false + for _, mod := range attr.PlanModifiers { + if strings.Contains(strings.ToLower(mod.Description(ctx)), "recreate") { + replaces = true + } + } + if !replaces { + t.Errorf("attribute %q should force replacement on change", name) + } + } + + tier, ok := s.Attributes["tier"].(rschema.StringAttribute) + if !ok { + t.Fatalf("tier attribute has wrong type: %T", s.Attributes["tier"]) + } + if len(tier.PlanModifiers) != 0 { + t.Error("tier should have no plan modifiers; it must be updatable in place") + } +} + +// TestGrantSchema_TierValidatorAcceptsTiers executes the tier validators: every +// real tier passes and an unknown value is rejected. +func TestGrantSchema_TierValidatorAcceptsTiers(t *testing.T) { + t.Parallel() + + s := grantTestSchema(t) + ctx := context.Background() + + tier, ok := s.Attributes["tier"].(rschema.StringAttribute) + if !ok { + t.Fatalf("tier attribute has wrong type: %T", s.Attributes["tier"]) + } + + validate := func(value string) diag.Diagnostics { + var resp validator.StringResponse + for _, v := range tier.Validators { + v.ValidateString(ctx, validator.StringRequest{ + Path: path.Root("tier"), + ConfigValue: types.StringValue(value), + }, &resp) + } + + return resp.Diagnostics + } + + for _, v := range tierValues() { + if d := validate(v); d.HasError() { + t.Errorf("tier %q should be accepted, got: %v", v, d) + } + } + if d := validate("bogus-tier"); !d.HasError() { + t.Error(`tier "bogus-tier" should be rejected`) + } +} + +func TestTierToString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tier authzv1.Tier + want string + wantErr bool + }{ + {name: "view", tier: authzv1.Tier_TIER_VIEW, want: "view"}, + {name: "use", tier: authzv1.Tier_TIER_USE, want: "use"}, + {name: "edit", tier: authzv1.Tier_TIER_EDIT, want: "edit"}, + {name: "errors on unspecified", tier: authzv1.Tier_TIER_UNSPECIFIED, wantErr: true}, + {name: "errors on out-of-range value", tier: authzv1.Tier(42), wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := tierToString(tc.tier) + if tc.wantErr { + if err == nil { + t.Fatalf("tierToString(%v) = %q, want error", tc.tier, got) + } + + return + } + if err != nil { + t.Fatalf("tierToString(%v): %v", tc.tier, err) + } + if got != tc.want { + t.Errorf("tierToString(%v) = %q, want %q", tc.tier, got, tc.want) + } + }) + } +} + +func TestTierFromString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want authzv1.Tier + wantErr bool + }{ + {name: "view", in: "view", want: authzv1.Tier_TIER_VIEW}, + {name: "use", in: "use", want: authzv1.Tier_TIER_USE}, + {name: "edit", in: "edit", want: authzv1.Tier_TIER_EDIT}, + {name: "rejects empty string", in: "", wantErr: true}, + {name: "rejects wrong case", in: "VIEW", wantErr: true}, + {name: "rejects unknown value", in: "admin", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := tierFromString(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("tierFromString(%q) = %v, want error", tc.in, got) + } + + return + } + if err != nil { + t.Fatalf("tierFromString(%q): %v", tc.in, err) + } + if got != tc.want { + t.Errorf("tierFromString(%q) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +// TestTierRoundTrip asserts every schema-accepted tier string survives +// string -> enum -> string unchanged, so the validator list and the +// converters can't drift apart. +func TestTierRoundTrip(t *testing.T) { + t.Parallel() + + for _, name := range tierValues() { + tier, err := tierFromString(name) + if err != nil { + t.Fatalf("tierFromString(%q): %v", name, err) + } + back, err := tierToString(tier) + if err != nil { + t.Fatalf("tierToString(%v): %v", tier, err) + } + if back != name { + t.Errorf("round trip of %q produced %q", name, back) + } + } +} + +func TestParseGrantImportID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id string + wantResource string + wantGrantee string + wantErr bool + }{ + { + name: "resource and grantee", + id: "s2r:prod:saml2idp:team1/res1,s2r:prod:user:u1", + wantResource: "s2r:prod:saml2idp:team1/res1", + wantGrantee: "s2r:prod:user:u1", + }, + {name: "rejects missing comma", id: "s2r:prod:saml2idp:team1/res1", wantErr: true}, + {name: "rejects empty string", id: "", wantErr: true}, + {name: "rejects empty resource part", id: ",s2r:prod:user:u1", wantErr: true}, + {name: "rejects empty grantee part", id: "s2r:prod:saml2idp:team1/res1,", wantErr: true}, + {name: "rejects extra comma", id: "a,b,c", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotResource, gotGrantee, err := parseGrantImportID(tc.id) + if tc.wantErr { + if err == nil { + t.Fatalf("parseGrantImportID(%q) = (%q, %q), want error", tc.id, gotResource, gotGrantee) + } + + return + } + if err != nil { + t.Fatalf("parseGrantImportID(%q): %v", tc.id, err) + } + if gotResource != tc.wantResource || gotGrantee != tc.wantGrantee { + t.Errorf("parseGrantImportID(%q) = (%q, %q), want (%q, %q)", tc.id, gotResource, gotGrantee, tc.wantResource, tc.wantGrantee) + } + }) + } +} diff --git a/splitsecure/services/org/group.go b/splitsecure/services/org/group.go new file mode 100644 index 0000000..2e38e96 --- /dev/null +++ b/splitsecure/services/org/group.go @@ -0,0 +1,475 @@ +package org + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "connectrpc.com/connect" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + orgsvcv1 "github.com/splitsecure/apis/gen/go/proto/splitsecure/orgsvc/v1" + "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/client" +) + +var ( + _ resource.Resource = (*groupResource)(nil) + _ resource.ResourceWithImportState = (*groupResource)(nil) +) + +const ( + titleCreatingGroup = "Creating group" + titleAddingMembers = "Adding group members" + titleReadingGroup = "Reading group" +) + +var ( + errEmptyGroup = errors.New("CreateGroup returned no group_s2r; the group may exist server-side but cannot be tracked in state") + errMembersNotAdded = errors.New("some members could not be added") + errEmptyMemberPrincipal = errors.New("ListGroupMembers returned a member with an empty principal_s2r") +) + +type groupResource struct { + client *client.Client +} + +type groupResourceModel struct { + GroupS2R types.String `tfsdk:"group_s2r"` + Name types.String `tfsdk:"name"` + Members types.Set `tfsdk:"members"` + Source types.String `tfsdk:"source"` +} + +// NewGroup returns a factory for the org group resource. +func NewGroup() resource.Resource { + return &groupResource{} +} + +func (r *groupResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_group" +} + +func (r *groupResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.client = clientFromProviderData(req.ProviderData, &resp.Diagnostics) +} + +func (r *groupResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Locally-managed principal group in the provider-configured organization. " + + "Terraform is authoritative over the member set; SCIM- and system-managed groups cannot be managed by this resource.", + Attributes: map[string]schema.Attribute{ + "group_s2r": schema.StringAttribute{ + Computed: true, + Description: "Group s2r URI. Stable identifier; also the import ID.", + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "name": schema.StringAttribute{ + Required: true, + Description: "Human-readable group name. Changing it updates the group in place.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "members": schema.SetAttribute{ + ElementType: types.StringType, + Optional: true, + Description: "Authoritative set of member principal s2r URIs (users or service accounts). " + + "Principals not listed here are removed on apply. Leave unset for an empty group.", + }, + "source": schema.StringAttribute{ + Computed: true, + Description: `Where the group is managed from: "local", "scim", or "system". Always "local" for Terraform-managed groups.`, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + }, + } +} + +func (r *groupResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan groupResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + configured := membersFromSet(ctx, plan.Members, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + groupS2R, src, err := r.createGroupShell(ctx, plan.Name.ValueString()) + if err != nil { + if groupS2R != "" { + // The group exists; record it so it is not orphaned from state. + plan.GroupS2R = types.StringValue(groupS2R) + plan.Source = types.StringNull() + if len(configured) > 0 { + plan.Members = types.SetNull(types.StringType) + } + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + } + resp.Diagnostics.AddError(titleCreatingGroup, err.Error()) + + return + } + plan.GroupS2R = types.StringValue(groupS2R) + plan.Source = types.StringValue(src) + + if len(configured) == 0 { + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + + return + } + + addResp, err := r.client.OrgService.AddGroupMembers(ctx, connect.NewRequest(&orgsvcv1.AddGroupMembersRequest{ + GroupS2R: groupS2R, + PrincipalS2Rs: configured, + })) + if err != nil { + // Membership is unknown after a wholesale RPC failure; record + // the group itself so it is not orphaned from state. + plan.Members = types.SetNull(types.StringType) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + resp.Diagnostics.AddError(titleAddingMembers, err.Error()) + + return + } + + landed, failures := partitionAddResults(addResp.Msg.GetResults()) + if len(failures) > 0 { + landedSet, d := types.SetValueFrom(ctx, types.StringType, landed) + resp.Diagnostics.Append(d...) + plan.Members = landedSet + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) + resp.Diagnostics.AddError( + titleAddingMembers, + fmt.Sprintf("group %s was created and recorded in state, but some members could not be added:\n%s", + groupS2R, strings.Join(failures, "\n")), + ) + + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *groupResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state groupResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + g, src := r.fetchLocalGroup(ctx, state.GroupS2R.ValueString(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + if g == nil { + resp.State.RemoveResource(ctx) + + return + } + + principals, err := r.listMemberPrincipals(ctx, g.GetGroupS2R()) + if err != nil { + resp.Diagnostics.AddError("Listing group members", err.Error()) + + return + } + + state.Name = types.StringValue(g.GetName()) + state.Source = types.StringValue(src) + // An unset members attribute means empty membership; keep it null + // when the group is in fact empty so imports and member-less + // configs stay diff-free. + if len(principals) > 0 || !state.Members.IsNull() { + membersVal, d := types.SetValueFrom(ctx, types.StringType, principals) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + state.Members = membersVal + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *groupResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state groupResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + groupS2R := state.GroupS2R.ValueString() + planMembers := membersFromSet(ctx, plan.Members, &resp.Diagnostics) + stateMembers := membersFromSet(ctx, state.Members, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + // applied tracks what has actually been persisted server-side so + // far; error paths write it to state because the framework + // pre-populates resp.State with the plan, which would otherwise + // record changes that never happened. + applied := state + applied.GroupS2R = types.StringValue(groupS2R) + + if !plan.Name.Equal(state.Name) { + _, err := r.client.OrgService.UpdateGroup(ctx, connect.NewRequest(&orgsvcv1.UpdateGroupRequest{ + GroupS2R: groupS2R, + Name: plan.Name.ValueString(), + })) + if err != nil { + resp.Diagnostics.Append(resp.State.Set(ctx, &applied)...) + resp.Diagnostics.AddError("Updating group name", err.Error()) + + return + } + applied.Name = plan.Name + } + + current, title, err := r.reconcileMembers(ctx, groupS2R, stateMembers, planMembers) + if err != nil { + membersVal, d := types.SetValueFrom(ctx, types.StringType, current) + resp.Diagnostics.Append(d...) + applied.Members = membersVal + resp.Diagnostics.Append(resp.State.Set(ctx, &applied)...) + resp.Diagnostics.AddError(title, err.Error()) + + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *groupResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state groupResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + _, err := r.client.OrgService.DeleteGroup(ctx, connect.NewRequest(&orgsvcv1.DeleteGroupRequest{ + GroupS2R: state.GroupS2R.ValueString(), + })) + if err != nil && connect.CodeOf(err) != connect.CodeNotFound { + resp.Diagnostics.AddError("Deleting group", err.Error()) + } +} + +func (r *groupResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("group_s2r"), req, resp) +} + +// createGroupShell creates the empty local group. A non-empty groupS2R +// alongside a non-nil error means the group exists server-side but its +// response was unusable; the caller must still record it in state. +func (r *groupResource) createGroupShell(ctx context.Context, name string) (string, string, error) { + createResp, err := r.client.OrgService.CreateGroup(ctx, connect.NewRequest(&orgsvcv1.CreateGroupRequest{ + OrgS2R: r.client.OrgS2R, + Name: name, + Source: orgsvcv1.GroupSource_GROUP_SOURCE_LOCAL, + })) + if err != nil { + return "", "", err + } + + g := createResp.Msg.GetGroup() + if g.GetGroupS2R() == "" { + return "", "", errEmptyGroup + } + + src, err := groupSourceToString(g.GetSource()) + if err != nil { + return g.GetGroupS2R(), "", err + } + + return g.GetGroupS2R(), src, nil +} + +// fetchLocalGroup returns the group and its source string when it +// exists and is locally managed. A nil group with no appended +// diagnostics means the group is gone (NotFound). +func (r *groupResource) fetchLocalGroup(ctx context.Context, groupS2R string, diags *diag.Diagnostics) (*orgsvcv1.Group, string) { + getResp, err := r.client.OrgService.GetGroup(ctx, connect.NewRequest(&orgsvcv1.GetGroupRequest{ + GroupS2R: groupS2R, + })) + if err != nil { + if connect.CodeOf(err) != connect.CodeNotFound { + diags.AddError(titleReadingGroup, err.Error()) + } + + return nil, "" + } + + g := getResp.Msg.GetGroup() + if g.GetGroupS2R() == "" { + diags.AddError(titleReadingGroup, "GetGroup returned an empty group for "+groupS2R) + + return nil, "" + } + if g.GetSource() != orgsvcv1.GroupSource_GROUP_SOURCE_LOCAL { + diags.AddError( + "Group is not locally managed", + fmt.Sprintf("%s has source %s; only locally-managed groups can be managed by Terraform. "+ + "SCIM groups are owned by the identity provider, and the system Everyone group is implicit.", + g.GetGroupS2R(), g.GetSource()), + ) + + return nil, "" + } + src, err := groupSourceToString(g.GetSource()) + if err != nil { + diags.AddError(titleReadingGroup, err.Error()) + + return nil, "" + } + + return g, src +} + +func (r *groupResource) listMemberPrincipals(ctx context.Context, groupS2R string) ([]string, error) { + principals := []string{} // non-nil: an empty group is an empty set, not null + cursor := "" + for { + membersResp, err := r.client.OrgService.ListGroupMembers(ctx, connect.NewRequest(&orgsvcv1.ListGroupMembersRequest{ + GroupS2R: groupS2R, + Cursor: cursor, + })) + if err != nil { + return nil, err + } + for _, m := range membersResp.Msg.GetMembers() { + p := m.GetPrincipalS2R() + if p == "" { + return nil, fmt.Errorf("%w for group %s", errEmptyMemberPrincipal, groupS2R) + } + principals = append(principals, p) + } + cursor = membersResp.Msg.GetNextCursor() + if cursor == "" { + return principals, nil + } + } +} + +// reconcileMembers applies the membership diff from state to plan: +// additions first (per-principal results, fail closed), then removals. +// The returned slice always reflects the principals that are members +// after the calls that actually succeeded, so error paths can persist +// an accurate state. +func (r *groupResource) reconcileMembers(ctx context.Context, groupS2R string, stateMembers, planMembers []string) ([]string, string, error) { + toAdd, toRemove := diffMembers(stateMembers, planMembers) + + current := make([]string, 0, len(stateMembers)+len(toAdd)) + current = append(current, stateMembers...) + + if len(toAdd) > 0 { + addResp, err := r.client.OrgService.AddGroupMembers(ctx, connect.NewRequest(&orgsvcv1.AddGroupMembersRequest{ + GroupS2R: groupS2R, + PrincipalS2Rs: toAdd, + })) + if err != nil { + return current, titleAddingMembers, err + } + landed, failures := partitionAddResults(addResp.Msg.GetResults()) + current = append(current, landed...) + if len(failures) > 0 { + return current, titleAddingMembers, + fmt.Errorf("%w to group %s:\n%s", errMembersNotAdded, groupS2R, strings.Join(failures, "\n")) + } + } + + for _, principal := range toRemove { + _, err := r.client.OrgService.RemoveGroupMember(ctx, connect.NewRequest(&orgsvcv1.RemoveGroupMemberRequest{ + GroupS2R: groupS2R, + PrincipalS2R: principal, + })) + // NotFound means the principal is already gone (deleted from the + // org, or removed out of band) — the desired end state is met, so + // treat it as a successful removal rather than failing the apply. + if err != nil && connect.CodeOf(err) != connect.CodeNotFound { + return current, "Removing group member", + fmt.Errorf("removing %s from group %s: %w", principal, groupS2R, err) + } + remaining := current[:0] + for _, m := range current { + if m != principal { + remaining = append(remaining, m) + } + } + current = remaining + } + + return current, "", nil +} + +// membersFromSet extracts the principal list from a members set; null +// and unknown sets mean no members. +func membersFromSet(ctx context.Context, s types.Set, diags *diag.Diagnostics) []string { + if s.IsNull() || s.IsUnknown() { + return nil + } + var out []string + diags.Append(s.ElementsAs(ctx, &out, false)...) + + return out +} + +// diffMembers computes the membership changes needed to go from state +// to plan. Output is sorted so API call order is deterministic. +func diffMembers(state, plan []string) ([]string, []string) { + inState := make(map[string]struct{}, len(state)) + for _, p := range state { + inState[p] = struct{}{} + } + inPlan := make(map[string]struct{}, len(plan)) + for _, p := range plan { + inPlan[p] = struct{}{} + } + + var toAdd, toRemove []string + for p := range inPlan { + if _, ok := inState[p]; !ok { + toAdd = append(toAdd, p) + } + } + for p := range inState { + if _, ok := inPlan[p]; !ok { + toRemove = append(toRemove, p) + } + } + sort.Strings(toAdd) + sort.Strings(toRemove) + + return toAdd, toRemove +} + +// partitionAddResults splits AddGroupMembers results into principals +// that are now members (AlreadyMember counts as success) and +// per-principal failure descriptions. +func partitionAddResults(results []*orgsvcv1.AddGroupMembersResponse_Result) ([]string, []string) { + added := make([]string, 0, len(results)) + var failures []string + for _, res := range results { + if res.GetError() != "" { + failures = append(failures, fmt.Sprintf("%s: %s", res.GetPrincipalS2R(), res.GetError())) + + continue + } + added = append(added, res.GetPrincipalS2R()) + } + + return added, failures +} diff --git a/splitsecure/services/org/group_data.go b/splitsecure/services/org/group_data.go new file mode 100644 index 0000000..26031fe --- /dev/null +++ b/splitsecure/services/org/group_data.go @@ -0,0 +1,140 @@ +package org + +import ( + "context" + "errors" + "fmt" + + "connectrpc.com/connect" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + orgsvcv1 "github.com/splitsecure/apis/gen/go/proto/splitsecure/orgsvc/v1" + "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/client" +) + +var _ datasource.DataSource = (*groupDataSource)(nil) + +var ( + errGroupNotFound = errors.New("group not found") + errUnknownGroupSource = errors.New("unknown group source") + errEmptyGroupResp = errors.New("server returned an empty group") +) + +type groupDataSource struct { + client *client.Client +} + +type groupDataSourceModel struct { + GroupS2R types.String `tfsdk:"group_s2r"` + Name types.String `tfsdk:"name"` + Source types.String `tfsdk:"source"` +} + +// NewGroupDataSource returns a factory for the splitsecure_group data +// source. +func NewGroupDataSource() datasource.DataSource { + return &groupDataSource{} +} + +func (d *groupDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_group" +} + +func (d *groupDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.client = clientFromProviderData(req.ProviderData, &resp.Diagnostics) +} + +func (d *groupDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Resolves an org group by its stable group_s2r, exposing its current name and source. " + + "Lookup is by group_s2r only: a group's name is mutable and not unique server-side, so it is not a " + + "stable key. The system Everyone group is not a regular group; read everyone_group_s2r from the " + + "splitsecure_organization data source instead.", + Attributes: map[string]schema.Attribute{ + "group_s2r": schema.StringAttribute{ + Required: true, + Description: "Group s2r URI to resolve. Also usable directly as a grant grantee.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "name": schema.StringAttribute{ + Computed: true, + Description: "Current group name. Mutable server-side, so do not treat it as an identifier.", + }, + "source": schema.StringAttribute{ + Computed: true, + Description: `Where the group is managed: "local", "scim", or "system".`, + }, + }, + } +} + +func (d *groupDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config groupDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + group := d.getByS2R(ctx, config.GroupS2R.ValueString(), &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + source, err := groupSourceToString(group.GetSource()) + if err != nil { + resp.Diagnostics.AddError("Decoding group source", err.Error()) + + return + } + + config.Name = types.StringValue(group.GetName()) + config.Source = types.StringValue(source) + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) +} + +// getByS2R resolves a group directly by its stable s2r. +func (d *groupDataSource) getByS2R(ctx context.Context, groupS2R string, diags *diag.Diagnostics) *orgsvcv1.Group { + getResp, err := d.client.OrgService.GetGroup(ctx, connect.NewRequest(&orgsvcv1.GetGroupRequest{ + GroupS2R: groupS2R, + })) + if err != nil { + if connect.CodeOf(err) == connect.CodeNotFound { + diags.AddError("Looking up group", fmt.Sprintf("%s: %s", errGroupNotFound, groupS2R)) + + return nil + } + diags.AddError("GetGroup", err.Error()) + + return nil + } + g := getResp.Msg.GetGroup() + if g.GetGroupS2R() == "" { + diags.AddError("Looking up group", fmt.Sprintf("%s for %s", errEmptyGroupResp, groupS2R)) + + return nil + } + + return g +} + +func groupSourceToString(s orgsvcv1.GroupSource) (string, error) { + switch s { + case orgsvcv1.GroupSource_GROUP_SOURCE_LOCAL: + return "local", nil + case orgsvcv1.GroupSource_GROUP_SOURCE_SCIM: + return "scim", nil + case orgsvcv1.GroupSource_GROUP_SOURCE_SYSTEM: + return "system", nil + case orgsvcv1.GroupSource_GROUP_SOURCE_UNSPECIFIED: + return "", fmt.Errorf("%w: %s", errUnknownGroupSource, s) + default: + return "", fmt.Errorf("%w: %s", errUnknownGroupSource, s) + } +} diff --git a/splitsecure/services/org/group_internal_test.go b/splitsecure/services/org/group_internal_test.go new file mode 100644 index 0000000..89ea287 --- /dev/null +++ b/splitsecure/services/org/group_internal_test.go @@ -0,0 +1,714 @@ +package org + +import ( + "context" + "errors" + "slices" + "strconv" + "strings" + "testing" + + "connectrpc.com/connect" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + rschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + + orgsvcv1 "github.com/splitsecure/apis/gen/go/proto/splitsecure/orgsvc/v1" + "github.com/splitsecure/apis/gen/go/proto/splitsecure/orgsvc/v1/orgsvcv1connect" + "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/client" +) + +var errStub = errors.New("stub rpc error") + +// fakeOrgClient embeds the OrgService client so only the methods a test +// exercises need overriding; any other call panics on the nil embed. +type fakeOrgClient struct { + orgsvcv1connect.OrgServiceClient + + removeErr error + getGroup *orgsvcv1.Group + getGroupErr error + addResults []*orgsvcv1.AddGroupMembersResponse_Result + addErr error + listMembers []*orgsvcv1.GroupMember + listPages [][]*orgsvcv1.GroupMember // when set, ListGroupMembers serves these pages via cursor + listErr error +} + +func (f *fakeOrgClient) RemoveGroupMember( + _ context.Context, _ *connect.Request[orgsvcv1.RemoveGroupMemberRequest], +) (*connect.Response[orgsvcv1.RemoveGroupMemberResponse], error) { + if f.removeErr != nil { + return nil, f.removeErr + } + + return connect.NewResponse(&orgsvcv1.RemoveGroupMemberResponse{}), nil +} + +func (f *fakeOrgClient) GetGroup( + _ context.Context, _ *connect.Request[orgsvcv1.GetGroupRequest], +) (*connect.Response[orgsvcv1.GetGroupResponse], error) { + if f.getGroupErr != nil { + return nil, f.getGroupErr + } + + return connect.NewResponse(&orgsvcv1.GetGroupResponse{Group: f.getGroup}), nil +} + +func (f *fakeOrgClient) AddGroupMembers( + _ context.Context, _ *connect.Request[orgsvcv1.AddGroupMembersRequest], +) (*connect.Response[orgsvcv1.AddGroupMembersResponse], error) { + if f.addErr != nil { + return nil, f.addErr + } + + return connect.NewResponse(&orgsvcv1.AddGroupMembersResponse{Results: f.addResults}), nil +} + +func (f *fakeOrgClient) ListGroupMembers( + _ context.Context, req *connect.Request[orgsvcv1.ListGroupMembersRequest], +) (*connect.Response[orgsvcv1.ListGroupMembersResponse], error) { + if f.listErr != nil { + return nil, f.listErr + } + if f.listPages != nil { // paginated mode: the cursor is the page index + i := 0 + if c := req.Msg.GetCursor(); c != "" { + i, _ = strconv.Atoi(c) + } + resp := &orgsvcv1.ListGroupMembersResponse{Members: f.listPages[i]} + if i+1 < len(f.listPages) { + resp.NextCursor = strconv.Itoa(i + 1) + } + + return connect.NewResponse(resp), nil + } + + return connect.NewResponse(&orgsvcv1.ListGroupMembersResponse{Members: f.listMembers}), nil +} + +func TestGroupDataSource_GetByS2R(t *testing.T) { + t.Parallel() + + d := &groupDataSource{client: &client.Client{ + OrgService: &fakeOrgClient{getGroup: &orgsvcv1.Group{ + GroupS2R: "s2r:test:group:x/y", + Name: "SRE", + Source: orgsvcv1.GroupSource_GROUP_SOURCE_LOCAL, + }}, + }} + + var diags diag.Diagnostics + g := d.getByS2R(context.Background(), "s2r:test:group:x/y", &diags) + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if g.GetName() != "SRE" || g.GetGroupS2R() != "s2r:test:group:x/y" { + t.Fatalf("unexpected group: %+v", g) + } +} + +func TestGroupDataSource_GetByS2R_NotFound(t *testing.T) { + t.Parallel() + + d := &groupDataSource{client: &client.Client{ + OrgService: &fakeOrgClient{getGroupErr: connect.NewError(connect.CodeNotFound, errStub)}, + }} + + var diags diag.Diagnostics + if g := d.getByS2R(context.Background(), "s2r:test:group:x/y", &diags); g != nil { + t.Fatalf("expected nil group on NotFound, got %+v", g) + } + if !diags.HasError() { + t.Fatal("expected a diagnostic on NotFound") + } +} + +func reconcileWithRemoveErr(t *testing.T, removeErr error) ([]string, string, error) { + t.Helper() + + r := &groupResource{client: &client.Client{ + OrgService: &fakeOrgClient{removeErr: removeErr}, + OrgS2R: "s2r:test:org:x", + }} + + // State lists one principal, plan lists none -> the member is removed. + return r.reconcileMembers(context.Background(), "s2r:test:group:x/y", []string{"s2r:test:usr:a"}, nil) +} + +func TestReconcileMembers_RemoveToleratesNotFound(t *testing.T) { + t.Parallel() + + current, title, err := reconcileWithRemoveErr(t, connect.NewError(connect.CodeNotFound, errStub)) + if err != nil { + t.Fatalf("NotFound removal should be tolerated, got error %v (%s)", err, title) + } + if len(current) != 0 { + t.Fatalf("principal should be dropped from current, got %v", current) + } +} + +func TestReconcileMembers_RemoveOtherErrorFails(t *testing.T) { + t.Parallel() + + current, title, err := reconcileWithRemoveErr(t, connect.NewError(connect.CodeInternal, errStub)) + if err == nil { + t.Fatal("non-NotFound removal error should fail the reconcile") + } + if title != "Removing group member" { + t.Fatalf("unexpected error title %q", title) + } + if !slices.Equal(current, []string{"s2r:test:usr:a"}) { + t.Fatalf("failed removal should leave the principal in current, got %v", current) + } +} + +func groupResourceSchema(t *testing.T) rschema.Schema { + t.Helper() + + resp := &resource.SchemaResponse{} + (&groupResource{}).Schema(context.Background(), resource.SchemaRequest{}, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("schema diagnostics: %v", resp.Diagnostics) + } + + return resp.Schema +} + +func TestGroupSchema_AttributeModes(t *testing.T) { + t.Parallel() + + s := groupResourceSchema(t) + + name, ok := s.Attributes["name"] + if !ok { + t.Fatal("name attribute missing from group schema") + } + if !name.IsRequired() { + t.Error("name should be Required") + } + + members, ok := s.Attributes["members"] + if !ok { + t.Fatal("members attribute missing from group schema") + } + if !members.IsOptional() || members.IsRequired() || members.IsComputed() { + t.Error("members should be Optional only (authoritative config-owned set)") + } + + for _, attrName := range []string{"group_s2r", "source"} { + attr, ok := s.Attributes[attrName] + if !ok { + t.Fatalf("computed attribute %q missing from group schema", attrName) + } + if !attr.IsComputed() { + t.Errorf("attribute %q should be Computed", attrName) + } + } +} + +func TestGroupSchema_MembersIsStringSet(t *testing.T) { + t.Parallel() + + s := groupResourceSchema(t) + + members, ok := s.Attributes["members"].(rschema.SetAttribute) + if !ok { + t.Fatalf("members is %T, want SetAttribute", s.Attributes["members"]) + } + if !members.ElementType.Equal(types.StringType) { + t.Errorf("members element type is %s, want string", members.ElementType) + } +} + +// TestGroupSchema_GroupS2RKeepsState catches removal of the +// UseStateForUnknown modifier, which would flip group_s2r to "known +// after apply" on every update. +func TestGroupSchema_GroupS2RKeepsState(t *testing.T) { + t.Parallel() + + s := groupResourceSchema(t) + + attr, ok := s.Attributes["group_s2r"].(rschema.StringAttribute) + if !ok { + t.Fatalf("group_s2r is %T, want StringAttribute", s.Attributes["group_s2r"]) + } + if len(attr.PlanModifiers) == 0 { + t.Error("group_s2r should carry the UseStateForUnknown plan modifier") + } +} + +func TestDiffMembers(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + state []string + plan []string + wantAdd []string + wantRemove []string + }{ + { + name: "no change", + state: []string{"s2r:us:user:a", "s2r:us:user:b"}, + plan: []string{"s2r:us:user:b", "s2r:us:user:a"}, + }, + { + name: "pure additions", + state: []string{"s2r:us:user:a"}, + plan: []string{"s2r:us:user:a", "s2r:us:user:c", "s2r:us:user:b"}, + wantAdd: []string{"s2r:us:user:b", "s2r:us:user:c"}, + }, + { + name: "pure removals", + state: []string{"s2r:us:user:a", "s2r:us:user:b"}, + plan: []string{"s2r:us:user:a"}, + wantRemove: []string{"s2r:us:user:b"}, + }, + { + name: "mixed add and remove", + state: []string{"s2r:us:user:a", "s2r:us:user:b"}, + plan: []string{"s2r:us:user:b", "s2r:us:sa:x"}, + wantAdd: []string{"s2r:us:sa:x"}, + wantRemove: []string{"s2r:us:user:a"}, + }, + { + name: "from empty", + plan: []string{"s2r:us:user:a"}, + wantAdd: []string{"s2r:us:user:a"}, + }, + { + name: "to empty", + state: []string{"s2r:us:user:a"}, + wantRemove: []string{"s2r:us:user:a"}, + }, + { + name: "both empty", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotAdd, gotRemove := diffMembers(tc.state, tc.plan) + if !slices.Equal(gotAdd, tc.wantAdd) { + t.Errorf("toAdd = %v, want %v", gotAdd, tc.wantAdd) + } + if !slices.Equal(gotRemove, tc.wantRemove) { + t.Errorf("toRemove = %v, want %v", gotRemove, tc.wantRemove) + } + }) + } +} + +func TestPartitionAddResults(t *testing.T) { + t.Parallel() + + results := []*orgsvcv1.AddGroupMembersResponse_Result{ + {PrincipalS2R: "s2r:us:user:ok"}, + {PrincipalS2R: "s2r:us:user:dupe", AlreadyMember: true}, + {PrincipalS2R: "s2r:us:user:bad", Error: "principal not found"}, + {PrincipalS2R: "s2r:us:sa:worse", Error: "not in org"}, + } + + added, failures := partitionAddResults(results) + + wantAdded := []string{"s2r:us:user:ok", "s2r:us:user:dupe"} + if !slices.Equal(added, wantAdded) { + t.Errorf("added = %v, want %v", added, wantAdded) + } + + if len(failures) != 2 { + t.Fatalf("got %d failures, want 2: %v", len(failures), failures) + } + for i, want := range []struct{ principal, reason string }{ + {"s2r:us:user:bad", "principal not found"}, + {"s2r:us:sa:worse", "not in org"}, + } { + if !strings.Contains(failures[i], want.principal) || !strings.Contains(failures[i], want.reason) { + t.Errorf("failures[%d] = %q, want it to mention %q and %q", i, failures[i], want.principal, want.reason) + } + } +} + +func TestPartitionAddResults_AllSucceed(t *testing.T) { + t.Parallel() + + added, failures := partitionAddResults([]*orgsvcv1.AddGroupMembersResponse_Result{ + {PrincipalS2R: "s2r:us:user:a"}, + {PrincipalS2R: "s2r:us:user:b", AlreadyMember: true}, + }) + + if !slices.Equal(added, []string{"s2r:us:user:a", "s2r:us:user:b"}) { + t.Errorf("added = %v, want both principals", added) + } + if len(failures) != 0 { + t.Errorf("failures = %v, want none", failures) + } +} + +func TestPartitionAddResults_EmptyInputYieldsNonNilAdded(t *testing.T) { + t.Parallel() + + added, failures := partitionAddResults(nil) + + // A non-nil slice matters: types.SetValueFrom turns nil into a + // null set, which would corrupt the partial-failure state write. + if added == nil { + t.Error("added should be non-nil for empty input") + } + if len(added) != 0 || len(failures) != 0 { + t.Errorf("added = %v, failures = %v, want both empty", added, failures) + } +} + +func TestFetchLocalGroup(t *testing.T) { + t.Parallel() + + const groupS2R = "s2r:test:group:x/y" + cases := []struct { + name string + group *orgsvcv1.Group + getErr error + wantGroup bool + wantSource string + wantErr bool + }{ + { + name: "local group resolves", + group: &orgsvcv1.Group{GroupS2R: groupS2R, Name: "SRE", Source: orgsvcv1.GroupSource_GROUP_SOURCE_LOCAL}, + wantGroup: true, + wantSource: "local", + }, + { + name: "scim group is rejected", + group: &orgsvcv1.Group{GroupS2R: groupS2R, Source: orgsvcv1.GroupSource_GROUP_SOURCE_SCIM}, + wantErr: true, + }, + { + name: "system group is rejected", + group: &orgsvcv1.Group{GroupS2R: groupS2R, Source: orgsvcv1.GroupSource_GROUP_SOURCE_SYSTEM}, + wantErr: true, + }, + { + name: "empty group is rejected", + group: &orgsvcv1.Group{}, + wantErr: true, + }, + { + // A nil group with no diagnostic is the contract Read relies on + // to RemoveResource; a surfaced error would abort the read instead. + name: "not found returns nil without a diagnostic", + getErr: connect.NewError(connect.CodeNotFound, errStub), + }, + { + name: "other rpc error is surfaced", + getErr: connect.NewError(connect.CodeInternal, errStub), + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := &groupResource{client: &client.Client{ + OrgService: &fakeOrgClient{getGroup: tc.group, getGroupErr: tc.getErr}, + }} + + var diags diag.Diagnostics + g, src := r.fetchLocalGroup(context.Background(), groupS2R, &diags) + if tc.wantErr { + if !diags.HasError() { + t.Fatalf("expected an error diagnostic, got group %+v", g) + } + + return + } + if diags.HasError() { + t.Fatalf("unexpected diagnostics: %v", diags) + } + if tc.wantGroup { + if g == nil { + t.Fatal("expected a group") + } + if src != tc.wantSource { + t.Fatalf("source = %q, want %q", src, tc.wantSource) + } + + return + } + if g != nil { + t.Fatalf("expected nil group, got %+v", g) + } + }) + } +} + +func TestListMemberPrincipals_RejectsEmptyPrincipal(t *testing.T) { + t.Parallel() + + r := &groupResource{client: &client.Client{ + OrgService: &fakeOrgClient{listMembers: []*orgsvcv1.GroupMember{ + {PrincipalS2R: "s2r:test:usr:a"}, + {PrincipalS2R: ""}, + }}, + }} + + _, err := r.listMemberPrincipals(context.Background(), "s2r:test:group:x/y") + if !errors.Is(err, errEmptyMemberPrincipal) { + t.Fatalf("err = %v, want errEmptyMemberPrincipal", err) + } +} + +func TestListMemberPrincipals_ConsumesAllPages(t *testing.T) { + t.Parallel() + + r := &groupResource{client: &client.Client{OrgService: &fakeOrgClient{ + listPages: [][]*orgsvcv1.GroupMember{ + {{PrincipalS2R: "s2r:test:usr:a"}, {PrincipalS2R: "s2r:test:usr:b"}}, + {{PrincipalS2R: "s2r:test:usr:c"}}, + }, + }}} + + got, err := r.listMemberPrincipals(context.Background(), "s2r:test:group:x/y") + if err != nil { + t.Fatalf("listMemberPrincipals: %v", err) + } + want := []string{"s2r:test:usr:a", "s2r:test:usr:b", "s2r:test:usr:c"} + if !slices.Equal(got, want) { + t.Fatalf("got %v, want %v (both pages must be consumed)", got, want) + } +} + +func TestReconcileMembers_AddPath(t *testing.T) { + t.Parallel() + + const groupS2R = "s2r:test:group:x/y" + + t.Run("all additions land", func(t *testing.T) { + t.Parallel() + + r := &groupResource{client: &client.Client{OrgService: &fakeOrgClient{ + addResults: []*orgsvcv1.AddGroupMembersResponse_Result{ + {PrincipalS2R: "s2r:test:usr:a"}, + {PrincipalS2R: "s2r:test:usr:b"}, + }, + }}} + + current, title, err := r.reconcileMembers(context.Background(), groupS2R, nil, []string{"s2r:test:usr:a", "s2r:test:usr:b"}) + if err != nil { + t.Fatalf("unexpected error: %v (%s)", err, title) + } + slices.Sort(current) + if !slices.Equal(current, []string{"s2r:test:usr:a", "s2r:test:usr:b"}) { + t.Fatalf("current = %v, want both principals", current) + } + }) + + t.Run("partial server rejection returns an error and keeps only landed", func(t *testing.T) { + t.Parallel() + + r := &groupResource{client: &client.Client{OrgService: &fakeOrgClient{ + addResults: []*orgsvcv1.AddGroupMembersResponse_Result{ + {PrincipalS2R: "s2r:test:usr:a"}, + {PrincipalS2R: "s2r:test:usr:b", Error: "not in org"}, + }, + }}} + + current, title, err := r.reconcileMembers(context.Background(), groupS2R, nil, []string{"s2r:test:usr:a", "s2r:test:usr:b"}) + if !errors.Is(err, errMembersNotAdded) { + t.Fatalf("err = %v, want errMembersNotAdded", err) + } + if title != titleAddingMembers { + t.Fatalf("title = %q, want %q", title, titleAddingMembers) + } + if !slices.Contains(current, "s2r:test:usr:a") { + t.Fatalf("landed member should be in current: %v", current) + } + if slices.Contains(current, "s2r:test:usr:b") { + t.Fatalf("failed member should not be in current: %v", current) + } + }) + + t.Run("add rpc error preserves prior members", func(t *testing.T) { + t.Parallel() + + r := &groupResource{client: &client.Client{OrgService: &fakeOrgClient{ + addErr: connect.NewError(connect.CodeInternal, errStub), + }}} + + current, title, err := r.reconcileMembers(context.Background(), groupS2R, []string{"s2r:test:usr:x"}, []string{"s2r:test:usr:x", "s2r:test:usr:a"}) + if err == nil { + t.Fatal("expected the add rpc error to fail the reconcile") + } + if title != titleAddingMembers { + t.Fatalf("title = %q, want %q", title, titleAddingMembers) + } + if !slices.Equal(current, []string{"s2r:test:usr:x"}) { + t.Fatalf("prior members should be preserved on add failure, got %v", current) + } + }) +} + +// TestGroupRead_MemberState covers the null-vs-empty member-set branch +// that keeps imported and member-less groups diff-free. +func TestGroupRead_MemberState(t *testing.T) { + t.Parallel() + + s := groupResourceSchema(t) + local := &orgsvcv1.Group{GroupS2R: "s2r:test:group:x/y", Name: "SRE", Source: orgsvcv1.GroupSource_GROUP_SOURCE_LOCAL} + + cases := []struct { + name string + listMembers []*orgsvcv1.GroupMember + priorMembers types.Set + wantNull bool + wantMembers []string + }{ + { + name: "populated membership is refreshed from the server", + listMembers: []*orgsvcv1.GroupMember{{PrincipalS2R: "s2r:test:usr:a"}, {PrincipalS2R: "s2r:test:usr:b"}}, + priorMembers: types.SetNull(types.StringType), + wantMembers: []string{"s2r:test:usr:a", "s2r:test:usr:b"}, + }, + { + name: "empty server membership stays null when prior state was null", + priorMembers: types.SetNull(types.StringType), + wantNull: true, + }, + { + name: "empty server membership becomes an empty set when prior state had members", + priorMembers: mustMemberSet(t, "s2r:test:usr:a"), + wantMembers: []string{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := &groupResource{client: &client.Client{ + OrgService: &fakeOrgClient{getGroup: local, listMembers: tc.listMembers}, + }} + req := resource.ReadRequest{State: groupStateForRead(t, s, groupResourceModel{ + GroupS2R: types.StringValue(local.GetGroupS2R()), + Name: types.StringValue("stale name"), + Members: tc.priorMembers, + Source: types.StringValue("local"), + })} + resp := &resource.ReadResponse{State: tfsdk.State{Schema: s}} + + r.Read(context.Background(), req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("unexpected diagnostics: %v", resp.Diagnostics) + } + + var got groupResourceModel + if diags := resp.State.Get(context.Background(), &got); diags.HasError() { + t.Fatalf("reading result state: %v", diags) + } + if got.Name.ValueString() != "SRE" { + t.Errorf("name = %q, want it refreshed to %q", got.Name.ValueString(), "SRE") + } + if tc.wantNull { + if !got.Members.IsNull() { + t.Fatalf("members should stay null, got %v", got.Members) + } + + return + } + if got.Members.IsNull() { + t.Fatal("members should be a non-null set") + } + var members []string + if diags := got.Members.ElementsAs(context.Background(), &members, false); diags.HasError() { + t.Fatalf("reading members: %v", diags) + } + slices.Sort(members) + if !slices.Equal(members, tc.wantMembers) { + t.Fatalf("members = %v, want %v", members, tc.wantMembers) + } + }) + } +} + +func TestGroupRead_RemovesOnNotFound(t *testing.T) { + t.Parallel() + + s := groupResourceSchema(t) + model := groupResourceModel{ + GroupS2R: types.StringValue("s2r:test:group:x/y"), + Name: types.StringValue("SRE"), + Members: types.SetNull(types.StringType), + Source: types.StringValue("local"), + } + r := &groupResource{client: &client.Client{ + OrgService: &fakeOrgClient{getGroupErr: connect.NewError(connect.CodeNotFound, errStub)}, + }} + req := resource.ReadRequest{State: groupStateForRead(t, s, model)} + // Seed the response with the prior state so a missing RemoveResource + // would leave it non-null and fail the assertion. + resp := &resource.ReadResponse{State: groupStateForRead(t, s, model)} + + r.Read(context.Background(), req, resp) + if resp.Diagnostics.HasError() { + t.Fatalf("NotFound should not surface a diagnostic: %v", resp.Diagnostics) + } + if !resp.State.Raw.IsNull() { + t.Fatal("state should be removed on NotFound") + } +} + +func TestGroupRead_ErrorsWithoutRemovingOnNonLocalSource(t *testing.T) { + t.Parallel() + + s := groupResourceSchema(t) + model := groupResourceModel{ + GroupS2R: types.StringValue("s2r:test:group:x/y"), + Name: types.StringValue("SRE"), + Members: types.SetNull(types.StringType), + Source: types.StringValue("local"), + } + r := &groupResource{client: &client.Client{ + OrgService: &fakeOrgClient{getGroup: &orgsvcv1.Group{ + GroupS2R: "s2r:test:group:x/y", + Source: orgsvcv1.GroupSource_GROUP_SOURCE_SCIM, + }}, + }} + req := resource.ReadRequest{State: groupStateForRead(t, s, model)} + resp := &resource.ReadResponse{State: groupStateForRead(t, s, model)} + + r.Read(context.Background(), req, resp) + if !resp.Diagnostics.HasError() { + t.Fatal("a SCIM-sourced group should fail the read") + } + if resp.State.Raw.IsNull() { + t.Fatal("state must not be removed when the read errors") + } +} + +func mustMemberSet(t *testing.T, principals ...string) types.Set { + t.Helper() + + set, diags := types.SetValueFrom(context.Background(), types.StringType, principals) + if diags.HasError() { + t.Fatalf("building member set: %v", diags) + } + + return set +} + +func groupStateForRead(t *testing.T, s rschema.Schema, model groupResourceModel) tfsdk.State { + t.Helper() + + state := tfsdk.State{Schema: s} + if diags := state.Set(context.Background(), &model); diags.HasError() { + t.Fatalf("building state: %v", diags) + } + + return state +} diff --git a/splitsecure/services/org/member_data.go b/splitsecure/services/org/member_data.go new file mode 100644 index 0000000..a000a9b --- /dev/null +++ b/splitsecure/services/org/member_data.go @@ -0,0 +1,139 @@ +package org + +import ( + "context" + "errors" + "fmt" + "strings" + + "connectrpc.com/connect" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + orgsvcv1 "github.com/splitsecure/apis/gen/go/proto/splitsecure/orgsvc/v1" + "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/client" +) + +var _ datasource.DataSource = (*memberDataSource)(nil) + +var ( + errNoOrgMember = errors.New("no org member") + errAmbiguousMemberEmail = errors.New("ambiguous member email") + errEmptyMemberUserID = errors.New("member resolved to an empty user_s2r") +) + +type memberDataSource struct { + client *client.Client +} + +type memberDataSourceModel struct { + Email types.String `tfsdk:"email"` + UserS2R types.String `tfsdk:"user_s2r"` + DisplayName types.String `tfsdk:"display_name"` +} + +// NewMemberDataSource returns a factory for the splitsecure_org_member +// data source. +func NewMemberDataSource() datasource.DataSource { + return &memberDataSource{} +} + +func (d *memberDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_org_member" +} + +func (d *memberDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.client = clientFromProviderData(req.ProviderData, &resp.Diagnostics) +} + +func (d *memberDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Looks up a single org member by email (case-insensitive). Errors if no member or more than one member matches.", + Attributes: map[string]schema.Attribute{ + "email": schema.StringAttribute{ + Required: true, + Description: "Email address of the member to look up. Matched case-insensitively.", + Validators: []validator.String{ + stringvalidator.LengthAtLeast(1), + }, + }, + "user_s2r": schema.StringAttribute{ + Computed: true, + Description: "User s2r URI of the member. Usable as a grant grantee or a group member principal.", + }, + "display_name": schema.StringAttribute{ + Computed: true, + Description: "Human-readable display name of the member.", + }, + }, + } +} + +func (d *memberDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config memberDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + email := config.Email.ValueString() + membersResp, err := d.client.OrgService.GetMembersByEmail(ctx, connect.NewRequest(&orgsvcv1.GetMembersByEmailRequest{ + Base: &orgsvcv1.GetMembersByEmailRequest_Base{ + OrganizationId: d.client.OrgS2R, + Emails: []string{email}, + }, + })) + if err != nil { + resp.Diagnostics.AddError("GetMembersByEmail", err.Error()) + + return + } + + member, err := singleMember(membersResp.Msg.GetResults(), email) + if err != nil { + resp.Diagnostics.AddError("Looking up org member", err.Error()) + + return + } + if member.GetUserId() == "" { + resp.Diagnostics.AddError("Looking up org member", fmt.Sprintf("%s: %s", errEmptyMemberUserID, email)) + + return + } + + config.UserS2R = types.StringValue(member.GetUserId()) + config.DisplayName = types.StringValue(member.GetDisplayName()) + resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) +} + +// singleMember extracts the one member the server resolved for email. The RPC +// returns one Result per requested email (in request order) with the matching +// already done server-side; here zero members means no such member and multiple +// means the address is ambiguous — both errors. +func singleMember(results []*orgsvcv1.GetMembersByEmailResponse_Result, email string) (*orgsvcv1.Member, error) { + var members []*orgsvcv1.Member + for _, r := range results { + if r.GetEmail() == email { + members = r.GetMembers() + + break + } + } + + switch len(members) { + case 0: + return nil, fmt.Errorf("%w with email %q", errNoOrgMember, email) + case 1: + return members[0], nil + default: + ids := make([]string, len(members)) + for i, m := range members { + ids[i] = m.GetUserId() + } + + return nil, fmt.Errorf("%w: %q matches %d members: %s", errAmbiguousMemberEmail, email, len(members), strings.Join(ids, ", ")) + } +} diff --git a/splitsecure/services/org/organization_data.go b/splitsecure/services/org/organization_data.go new file mode 100644 index 0000000..6d48072 --- /dev/null +++ b/splitsecure/services/org/organization_data.go @@ -0,0 +1,98 @@ +package org + +import ( + "context" + "errors" + "fmt" + + "connectrpc.com/connect" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + orgsvcv1 "github.com/splitsecure/apis/gen/go/proto/splitsecure/orgsvc/v1" + "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/client" +) + +var _ datasource.DataSource = (*organizationDataSource)(nil) + +var ( + errEmptyOrganization = errors.New("GetOrganization returned an empty organization") + errEmptyEveryoneGroup = errors.New("GetOrganization returned an empty everyone_group_s2r") +) + +type organizationDataSource struct { + client *client.Client +} + +type organizationDataSourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + EveryoneGroupS2R types.String `tfsdk:"everyone_group_s2r"` +} + +// NewOrganizationDataSource returns a factory for the +// splitsecure_organization data source. +func NewOrganizationDataSource() datasource.DataSource { + return &organizationDataSource{} +} + +func (d *organizationDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_organization" +} + +func (d *organizationDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.client = clientFromProviderData(req.ProviderData, &resp.Diagnostics) +} + +func (d *organizationDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "The organization the provider is configured against. Takes no arguments; the org comes from the provider's org_s2r.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "Org s2r URI.", + }, + "name": schema.StringAttribute{ + Computed: true, + Description: "Human-readable organization name.", + }, + "everyone_group_s2r": schema.StringAttribute{ + Computed: true, + Description: "S2R of the org's system Everyone group -- the grantee to use for org-wide grants. " + + "System groups are NOT returned by group listings (including the splitsecure_group data source); " + + "this attribute is the way to obtain it.", + }, + }, + } +} + +func (d *organizationDataSource) Read(ctx context.Context, _ datasource.ReadRequest, resp *datasource.ReadResponse) { + getResp, err := d.client.OrgService.GetOrganization(ctx, connect.NewRequest(&orgsvcv1.GetOrganizationRequest{ + Base: &orgsvcv1.GetOrganizationRequest_Base{OrganizationId: d.client.OrgS2R}, + })) + if err != nil { + resp.Diagnostics.AddError("GetOrganization", err.Error()) + + return + } + + o := getResp.Msg.GetOrganization() + if o == nil { + resp.Diagnostics.AddError("Reading organization", fmt.Errorf("%w for %s", errEmptyOrganization, d.client.OrgS2R).Error()) + + return + } + if o.GetEveryoneGroupS2R() == "" { + resp.Diagnostics.AddError("Reading organization", fmt.Errorf("%w for %s", errEmptyEveryoneGroup, d.client.OrgS2R).Error()) + + return + } + + state := organizationDataSourceModel{ + ID: types.StringValue(o.GetId()), + Name: types.StringValue(o.GetName()), + EveryoneGroupS2R: types.StringValue(o.GetEveryoneGroupS2R()), + } + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/splitsecure/services/org/principal_data.go b/splitsecure/services/org/principal_data.go new file mode 100644 index 0000000..dcb8406 --- /dev/null +++ b/splitsecure/services/org/principal_data.go @@ -0,0 +1,148 @@ +package org + +import ( + "context" + "errors" + "fmt" + "strings" + + "connectrpc.com/connect" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + orgsvcv1 "github.com/splitsecure/apis/gen/go/proto/splitsecure/orgsvc/v1" + "github.com/splitsecure/terraform-provider-splitsecure/splitsecure/client" +) + +var _ datasource.DataSource = (*principalDataSource)(nil) + +var ( + errNoPrincipal = errors.New("no principal") + errAmbiguousPrincipal = errors.New("email resolves to multiple principals") + errBadPrincipalS2R = errors.New("resolved principal s2r is malformed") +) + +type principalDataSource struct { + client *client.Client +} + +type principalDataSourceModel struct { + Email types.String `tfsdk:"email"` + S2R types.String `tfsdk:"s2r"` + Kind types.String `tfsdk:"kind"` + DisplayName types.String `tfsdk:"display_name"` +} + +// NewPrincipalDataSource returns a factory for the splitsecure_principal +// data source. +func NewPrincipalDataSource() datasource.DataSource { + return &principalDataSource{} +} + +func (d *principalDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_principal" +} + +func (d *principalDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + d.client = clientFromProviderData(req.ProviderData, &resp.Diagnostics) +} + +func (d *principalDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Resolves an org principal (user or service account) to its s2r by the email shown in the console. " + + "Use the s2r as a group member or grant grantee.", + Attributes: map[string]schema.Attribute{ + "email": schema.StringAttribute{ + Required: true, + Description: "Email of the principal, copy-pasted from the console. Matched case-insensitively.", + Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, + }, + "s2r": schema.StringAttribute{ + Computed: true, + Description: "Principal s2r URI (usr: for users, sa: for service accounts). Usable as a group member or grant grantee.", + }, + "kind": schema.StringAttribute{ + Computed: true, + Description: `Principal kind: "user" or "service_account".`, + }, + "display_name": schema.StringAttribute{ + Computed: true, + Description: "Display name (user) or name (service account).", + }, + }, + } +} + +func (d *principalDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var config principalDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + email := config.Email.ValueString() + + // GetMembersByEmail resolves users and service accounts alike, server-side — + // no member roster scan and no client-side service-account parsing. + membersResp, err := d.client.OrgService.GetMembersByEmail(ctx, connect.NewRequest(&orgsvcv1.GetMembersByEmailRequest{ + Base: &orgsvcv1.GetMembersByEmailRequest_Base{ + OrganizationId: d.client.OrgS2R, + Emails: []string{email}, + }, + })) + if err != nil { + resp.Diagnostics.AddError("GetMembersByEmail", err.Error()) + + return + } + + // One email in, one result out (results are per requested email); its members + // are the matches for that email. + results := membersResp.Msg.GetResults() + var members []*orgsvcv1.Member + if len(results) > 0 { + members = results[0].GetMembers() + } + switch { + case len(members) == 0: + resp.Diagnostics.AddError("Looking up principal", fmt.Sprintf("%s with email %q", errNoPrincipal, email)) + + return + case len(members) > 1: + resp.Diagnostics.AddError("Looking up principal", fmt.Sprintf("%s: %q -> %d principals", errAmbiguousPrincipal, email, len(members))) + + return + } + + member := members[0] + kind, err := principalKindFromS2R(member.GetUserId()) + if err != nil { + resp.Diagnostics.AddError("Looking up principal", err.Error()) + + return + } + + config.S2R = types.StringValue(member.GetUserId()) + config.Kind = types.StringValue(kind) + config.DisplayName = types.StringValue(member.GetDisplayName()) + resp.Diagnostics.Append(resp.State.Set(ctx, config)...) +} + +// principalKindFromS2R maps the kind segment of a principal s2r +// (s2r:{deployment}:{kind}:{id}) to the data source's kind value. +func principalKindFromS2R(s2r string) (string, error) { + parts := strings.SplitN(s2r, ":", 4) + if len(parts) < 4 || parts[0] != "s2r" || parts[1] == "" || parts[3] == "" { + return "", fmt.Errorf("%w: %q", errBadPrincipalS2R, s2r) + } + switch parts[2] { + case "usr": + return "user", nil + case "sa": + return "service_account", nil + default: + return "", fmt.Errorf("%w: %q (unexpected kind %q)", errBadPrincipalS2R, s2r, parts[2]) + } +} diff --git a/splitsecure/services/org/principal_data_internal_test.go b/splitsecure/services/org/principal_data_internal_test.go new file mode 100644 index 0000000..2c419da --- /dev/null +++ b/splitsecure/services/org/principal_data_internal_test.go @@ -0,0 +1,44 @@ +package org + +import "testing" + +func TestPrincipalKindFromS2R(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + s2r string + want string + wantError bool + }{ + {name: "user", s2r: "s2r:local-aliaksei:usr:abc123", want: "user"}, + {name: "service account", s2r: "s2r:local-aliaksei:sa:abc123", want: "service_account"}, + {name: "unexpected kind", s2r: "s2r:us:group:abc123", wantError: true}, + {name: "too few segments", s2r: "s2r:us:usr", wantError: true}, + {name: "empty deployment", s2r: "s2r::usr:abc123", wantError: true}, + {name: "empty id", s2r: "s2r:us:usr:", wantError: true}, + {name: "not an s2r", s2r: "usr:abc123", wantError: true}, + {name: "empty", s2r: "", wantError: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := principalKindFromS2R(tc.s2r) + if tc.wantError { + if err == nil { + t.Fatalf("principalKindFromS2R(%q) = %q, want error", tc.s2r, got) + } + + return + } + if err != nil { + t.Fatalf("principalKindFromS2R(%q): %v", tc.s2r, err) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} diff --git a/templates/index.md.tmpl b/templates/index.md.tmpl index 598f615..431dc4c 100644 --- a/templates/index.md.tmpl +++ b/templates/index.md.tmpl @@ -19,6 +19,8 @@ description: |- ## Full Example -End-to-end wiring: a SplitSecure team with a SAML2 IdP, mirrored as `aws_iam_saml_provider` on the AWS side, with admin and readonly IAM roles and a single SP allowing federation into both. Lives at [`examples/full/main.tf`](https://github.com/splitsecure/terraform-provider-splitsecure/tree/main/examples/full) in the repo. +End-to-end wiring: a SplitSecure team with a SAML2 IdP, mirrored as `aws_iam_saml_provider` on the AWS side, with admin and readonly IAM roles and a single SP allowing federation into both. Lives at [`examples/full`](https://github.com/splitsecure/terraform-provider-splitsecure/tree/main/examples/full) in the repo. + +{{tffile "examples/full/variables.tf"}} {{tffile "examples/full/main.tf"}}