Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add JAAS cloud access resource #581

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/provider/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
LogResourceAccessSecret = "resource-access-secret"

LogResourceJAASAccessModel = "resource-jaas-access-model"
LogResourceJAASAccessCloud = "resource-jaas-access-cloud"
LogResourceJAASGroup = "resource-jaas-group"
)

Expand Down
1 change: 1 addition & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ func (p *jujuProvider) Resources(_ context.Context) []func() resource.Resource {
func() resource.Resource { return NewSecretResource() },
func() resource.Resource { return NewAccessSecretResource() },
func() resource.Resource { return NewJAASAccessModelResource() },
func() resource.Resource { return NewJAASAccessCloudResource() },
func() resource.Resource { return NewJAASGroupResource() },
}
}
Expand Down
26 changes: 13 additions & 13 deletions internal/provider/resource_access_generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ type Setter interface {
// resourcer defines how the [genericJAASAccessResource] can query/save for information
// on the target object.
type resourcer interface {
Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessModel, names.Tag)
Save(ctx context.Context, setter Setter, info genericJAASAccessModel, tag names.Tag) diag.Diagnostics
Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessData, names.Tag)
Save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics
ImportHint() string
}

Expand All @@ -67,10 +67,10 @@ type genericJAASAccessResource struct {
subCtx context.Context
}

// genericJAASAccessModel represents a partial generic object for access management.
// genericJAASAccessData represents a partial generic object for access management.
// This struct should be embedded into a struct that contains a field for a target object (normally a name or UUID).
// Note that service accounts are treated as users but kept as a separate field for improved validation.
type genericJAASAccessModel struct {
type genericJAASAccessData struct {
Users types.Set `tfsdk:"users"`
ServiceAccounts types.Set `tfsdk:"service_accounts"`
Groups types.Set `tfsdk:"groups"`
Expand Down Expand Up @@ -312,7 +312,7 @@ func (resource *genericJAASAccessResource) Update(ctx context.Context, req resou
resp.Diagnostics.Append(resource.save(ctx, &resp.State, plan, targetTag)...)
}

func diffModels(plan, state genericJAASAccessModel, diag *diag.Diagnostics) (toAdd, toRemove genericJAASAccessModel) {
func diffModels(plan, state genericJAASAccessData, diag *diag.Diagnostics) (toAdd, toRemove genericJAASAccessData) {
newUsers := diffSet(plan.Users, state.Users, diag)
newGroups := diffSet(plan.Groups, state.Groups, diag)
newServiceAccounts := diffSet(plan.ServiceAccounts, state.ServiceAccounts, diag)
Expand Down Expand Up @@ -379,7 +379,7 @@ func (resource *genericJAASAccessResource) Delete(ctx context.Context, req resou
}

// modelToTuples return a list of tuples based on the access model provided.
func modelToTuples(ctx context.Context, targetTag names.Tag, model genericJAASAccessModel, diag *diag.Diagnostics) []juju.JaasTuple {
func modelToTuples(ctx context.Context, targetTag names.Tag, model genericJAASAccessData, diag *diag.Diagnostics) []juju.JaasTuple {
var users []string
var groups []string
var serviceAccounts []string
Expand All @@ -395,7 +395,7 @@ func modelToTuples(ctx context.Context, targetTag names.Tag, model genericJAASAc
}
var tuples []juju.JaasTuple
userNameToTagf := func(s string) string { return names.NewUserTag(s).String() }
groupIDToTagf := func(s string) string { return jimmnames.NewGroupTag(s).String() }
groupIDToTagf := func(s string) string { return jimmnames.NewGroupTag(s).String() + "#member" }
// Note that service accounts are treated as users but with an @serviceaccount domain.
// We add the @serviceaccount domain by calling `EnsureValidServiceAccountId` so that the user writing the plan doesn't have to.
// We can ignore the error below because the inputs have already gone through validation.
Expand All @@ -410,7 +410,7 @@ func modelToTuples(ctx context.Context, targetTag names.Tag, model genericJAASAc
}

// tuplesToModel does the reverse of planToTuples converting a slice of tuples to an access model.
func tuplesToModel(ctx context.Context, tuples []juju.JaasTuple, diag *diag.Diagnostics) genericJAASAccessModel {
func tuplesToModel(ctx context.Context, tuples []juju.JaasTuple, diag *diag.Diagnostics) genericJAASAccessData {
var users []string
var groups []string
var serviceAccounts []string
Expand All @@ -435,7 +435,7 @@ func tuplesToModel(ctx context.Context, tuples []juju.JaasTuple, diag *diag.Diag
users = append(users, userTag.Id())
}
case jimmnames.GroupTagKind:
groups = append(groups, tag.Id())
groups = append(groups, strings.ReplaceAll(tag.Id(), "#member", ""))
}
}
userSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, users)
Expand All @@ -444,7 +444,7 @@ func tuplesToModel(ctx context.Context, tuples []juju.JaasTuple, diag *diag.Diag
diag.Append(errDiag...)
serviceAccountSet, errDiag := basetypes.NewSetValueFrom(ctx, types.StringType, serviceAccounts)
diag.Append(errDiag...)
var model genericJAASAccessModel
var model genericJAASAccessData
model.Users = userSet
model.Groups = groupSet
model.ServiceAccounts = serviceAccountSet
Expand All @@ -461,11 +461,11 @@ func assignTupleObject(baseTuple juju.JaasTuple, items []string, idToTag func(st
return tuples
}

func (a *genericJAASAccessResource) info(ctx context.Context, getter Getter, diags *diag.Diagnostics) (genericJAASAccessModel, names.Tag) {
func (a *genericJAASAccessResource) info(ctx context.Context, getter Getter, diags *diag.Diagnostics) (genericJAASAccessData, names.Tag) {
return a.targetResource.Info(ctx, getter, diags)
}

func (a *genericJAASAccessResource) save(ctx context.Context, setter Setter, info genericJAASAccessModel, tag names.Tag) diag.Diagnostics {
func (a *genericJAASAccessResource) save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics {
return a.targetResource.Save(ctx, setter, info, tag)
}

Expand Down Expand Up @@ -505,7 +505,7 @@ func (a *genericJAASAccessResource) ImportState(ctx context.Context, req resourc
resp.Diagnostics.AddError(
"ImportState Failure",
fmt.Sprintf("Malformed Import ID %q, "+
"%s is not a valid tag", IDstr, resID[0]),
"%s is not a valid tag, expected %q", IDstr, resID[0], a.targetResource.ImportHint()),
)
return
}
Expand Down
106 changes: 106 additions & 0 deletions internal/provider/resource_access_generic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@
package provider

import (
"fmt"
"testing"

"github.com/canonical/jimm-go-sdk/v3/api"
"github.com/canonical/jimm-go-sdk/v3/api/params"
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
"github.com/hashicorp/terraform-plugin-testing/terraform"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -66,3 +71,104 @@ func TestAvoidAtSymbolValidation(t *testing.T) {
})
}
}

// ===============================
// Helpers for jaas resource tests

// newCheckAttribute returns a fetchComputedAttribute object that can be used in tests
// where you want to obtain the value of a computed attributed.
//
// The tag and resourceID fields are empty until this object is passed to a function
// like testAccCheckAttributeNotEmpty.
//
// The relationBuilder parameter allows you to create a custom string
// from the retrieved attribute value that can be used elsewhere in your test.
// The output of this function is stored on the tag field.
func newCheckAttribute(resourceName, attribute string, relationBuilder func(s string) string) fetchComputedAttribute {
var resourceID string
var tag string
return fetchComputedAttribute{
resourceName: resourceName,
attribute: attribute,
resourceID: &resourceID,
tag: &tag,
tagConstructor: relationBuilder,
}
}

type fetchComputedAttribute struct {
resourceName string
attribute string
resourceID *string
tag *string
tagConstructor func(s string) string
}

// testAccCheckAttributeNotEmpty is used used alongside newCheckAttribute
// to fetch an attribute value and verify that it is not empty.
func testAccCheckAttributeNotEmpty(check fetchComputedAttribute) resource.TestCheckFunc {
return func(s *terraform.State) error {
// retrieve the resource by name from state
rs, ok := s.RootModule().Resources[check.resourceName]
if !ok {
return fmt.Errorf("Not found: %s", check.resourceName)
}

val, ok := rs.Primary.Attributes[check.attribute]
if !ok {
return fmt.Errorf("%s is not set", check.attribute)
}
if val == "" {
return fmt.Errorf("%s is empty", check.attribute)
}
if check.resourceID == nil || check.tag == nil {
return fmt.Errorf("cannot set resource info, nil poiner")
}
*check.resourceID = val
*check.tag = check.tagConstructor(val)
return nil
}
}

// testAccCheckJaasResourceAccess verifies that no direct relations exist
// between the object and target.
// Object and target are expected to be Juju tags of the form <resource-type>:<id>
// Use newCheckAttribute to fetch and format resource tags from computed resources.
func testAccCheckJaasResourceAccess(relation string, object, target *string, expectedAccess bool) resource.TestCheckFunc {
return func(s *terraform.State) error {
if object == nil {
return fmt.Errorf("no object set")
}
if target == nil {
return fmt.Errorf("no target set")
}
conn, err := TestClient.Models.GetConnection(nil)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
jc := api.NewClient(conn)
req := params.ListRelationshipTuplesRequest{
Tuple: params.RelationshipTuple{
Object: *object,
Relation: relation,
TargetObject: *target,
},
}
resp, err := jc.ListRelationshipTuples(&req)
if err != nil {
return err
}
hasAccess := len(resp.Tuples) != 0
if hasAccess != expectedAccess {
var accessMsg string
if expectedAccess {
accessMsg = "access"
} else {
accessMsg = "no access"
}
return fmt.Errorf("expected %s for %s as %s to resource (%s), but access is %t", accessMsg, *object, relation, *target, hasAccess)
}
return nil
}
}
111 changes: 111 additions & 0 deletions internal/provider/resource_access_jaas_cloud.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright 2024 Canonical Ltd.
// Licensed under the Apache License, Version 2.0, see LICENCE file for details.

package provider

import (
"context"

"github.com/hashicorp/terraform-plugin-framework/diag"
"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"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
"github.com/juju/names/v5"
)

// Ensure provider defined types fully satisfy framework interfaces.
var _ resource.Resource = &jaasAccessCloudResource{}
var _ resource.ResourceWithConfigure = &jaasAccessCloudResource{}
var _ resource.ResourceWithImportState = &jaasAccessCloudResource{}
var _ resource.ResourceWithConfigValidators = &jaasAccessCloudResource{}

// NewJAASAccessCloudResource returns a new resource for JAAS cloud access.
func NewJAASAccessCloudResource() resource.Resource {
return &jaasAccessCloudResource{genericJAASAccessResource: genericJAASAccessResource{
targetResource: cloudInfo{},
resourceLogName: LogResourceJAASAccessCloud,
}}
}

type cloudInfo struct{}

// Info implements the [resourceInfo] interface, used to extract the info from a Terraform plan/state.
func (j cloudInfo) Info(ctx context.Context, getter Getter, diag *diag.Diagnostics) (genericJAASAccessData, names.Tag) {
cloudAccess := jaasAccessCloudResourceCloud{}
diag.Append(getter.Get(ctx, &cloudAccess)...)
accessCloud := genericJAASAccessData{
ID: cloudAccess.ID,
Users: cloudAccess.Users,
Groups: cloudAccess.Groups,
ServiceAccounts: cloudAccess.ServiceAccounts,
Access: cloudAccess.Access,
}
// When importing, the cloud name will be empty
var tag names.Tag
if cloudAccess.CloudName.ValueString() != "" {
tag = names.NewCloudTag(cloudAccess.CloudName.ValueString())
}
return accessCloud, tag
}

// Save implements the [resourceInfo] interface, used to save info on Terraform's state.
func (j cloudInfo) Save(ctx context.Context, setter Setter, info genericJAASAccessData, tag names.Tag) diag.Diagnostics {
cloudAccess := jaasAccessCloudResourceCloud{
CloudName: basetypes.NewStringValue(tag.Id()),
ID: info.ID,
Users: info.Users,
Groups: info.Groups,
ServiceAccounts: info.ServiceAccounts,
Access: info.Access,
}
return setter.Set(ctx, cloudAccess)
}

// ImportHint implements [resourceInfo] and provides a hint to users on the import string format.
func (j cloudInfo) ImportHint() string {
return "cloud-<name>:<access-level>"
}

type jaasAccessCloudResource struct {
genericJAASAccessResource
}

type jaasAccessCloudResourceCloud struct {
CloudName types.String `tfsdk:"cloud_name"`
Users types.Set `tfsdk:"users"`
ServiceAccounts types.Set `tfsdk:"service_accounts"`
Groups types.Set `tfsdk:"groups"`
Access types.String `tfsdk:"access"`

// ID required for imports
ID types.String `tfsdk:"id"`
}

// Metadata returns metadata about the JAAS cloud access resource.
func (a *jaasAccessCloudResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_jaas_access_cloud"
}

// Schema defines the schema for the JAAS cloud access resource.
func (a *jaasAccessCloudResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
attributes := a.partialAccessSchema()
attributes["cloud_name"] = schema.StringAttribute{
Description: "The name of the cloud for access management. If this is changed the resource will be deleted and a new resource will be created.",
Required: true,
Validators: []validator.String{
ValidatorMatchString(names.IsValidCloud, "cloud must be a valid name"),
},
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
}
schema := schema.Schema{
Description: "A resource that represents access to a cloud when using JAAS.",
Attributes: attributes,
}
resp.Schema = schema
}
Loading