tfctl 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rspec +1 -0
- data/.rubocop.yml +79 -0
- data/.travis.yml +18 -0
- data/CHANGELOG.adoc +29 -0
- data/Gemfile +5 -0
- data/Guardfile +7 -0
- data/LICENSE +19 -0
- data/Makefile +36 -0
- data/README.adoc +176 -0
- data/bin/tfctl +223 -0
- data/docs/configuration.adoc +89 -0
- data/docs/control_tower.adoc +211 -0
- data/docs/creating_a_profile.adoc +191 -0
- data/docs/iam_permissions.adoc +38 -0
- data/docs/project_layout.adoc +65 -0
- data/examples/bootstrap/terraform-exec-role.template +24 -0
- data/examples/bootstrap/terraform-state.template +81 -0
- data/examples/bootstrap/tfctl-org-access.template +42 -0
- data/examples/control_tower/conf/example.yaml +80 -0
- data/examples/control_tower/modules/s3-bucket/main.tf +4 -0
- data/examples/control_tower/modules/s3-bucket/variables.tf +4 -0
- data/examples/control_tower/profiles/example-profile/data.tf +1 -0
- data/examples/control_tower/profiles/example-profile/main.tf +4 -0
- data/examples/control_tower/profiles/example-profile/variables.tf +12 -0
- data/lib/hash.rb +33 -0
- data/lib/tfctl.rb +10 -0
- data/lib/tfctl/aws_org.rb +112 -0
- data/lib/tfctl/config.rb +182 -0
- data/lib/tfctl/error.rb +15 -0
- data/lib/tfctl/executor.rb +103 -0
- data/lib/tfctl/generator.rb +88 -0
- data/lib/tfctl/logger.rb +52 -0
- data/lib/tfctl/schema.rb +80 -0
- data/lib/tfctl/version.rb +5 -0
- data/tfctl.gemspec +36 -0
- metadata +179 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
// Settings:
|
2
|
+
:idprefix:
|
3
|
+
:idseparator: -
|
4
|
+
ifndef::env-github[:icons: font]
|
5
|
+
ifdef::env-github,env-browser[]
|
6
|
+
:toc: macro
|
7
|
+
:toclevels: 1
|
8
|
+
endif::[]
|
9
|
+
ifdef::env-github[]
|
10
|
+
:branch: master
|
11
|
+
:status:
|
12
|
+
:outfilesuffix: .adoc
|
13
|
+
:!toc-title:
|
14
|
+
:caution-caption: :fire:
|
15
|
+
:important-caption: :exclamation:
|
16
|
+
:note-caption: :paperclip:
|
17
|
+
:tip-caption: :bulb:
|
18
|
+
:warning-caption: :warning:
|
19
|
+
endif::[]
|
20
|
+
|
21
|
+
= IAM roles
|
22
|
+
|
23
|
+
Tfctl usually requires three IAM roles to be configured:
|
24
|
+
|
25
|
+
* `TfctlRole` - read only access to AWS Organizations set up in the primary account.
|
26
|
+
* `TerraformStateRole` - access to remote state resources (S3, DynamoDB) in the
|
27
|
+
account where your state is stored (can be any account).
|
28
|
+
* `TerraformExecutionRole` - configured in all spoke accounts and used for executing Terraform.
|
29
|
+
|
30
|
+
The user executing tfctl needs permission to assume all three roles cross
|
31
|
+
account. Tfctl will assume roles automatically for you.
|
32
|
+
|
33
|
+
It's possible to configure different Terraform execution roles in different
|
34
|
+
spoke accounts based on OU or account names. This can be used to restrict
|
35
|
+
Terraform in certain accounts.
|
36
|
+
|
37
|
+
We usually set those roles up using CloudFormation as part of the bootstrapping
|
38
|
+
process. See example templates in `examples/bootstrap/`.
|
@@ -0,0 +1,65 @@
|
|
1
|
+
// Settings:
|
2
|
+
:idprefix:
|
3
|
+
:idseparator: -
|
4
|
+
ifndef::env-github[:icons: font]
|
5
|
+
ifdef::env-github,env-browser[]
|
6
|
+
:toc: macro
|
7
|
+
:toclevels: 1
|
8
|
+
endif::[]
|
9
|
+
ifdef::env-github[]
|
10
|
+
:branch: master
|
11
|
+
:status:
|
12
|
+
:outfilesuffix: .adoc
|
13
|
+
:!toc-title:
|
14
|
+
:caution-caption: :fire:
|
15
|
+
:important-caption: :exclamation:
|
16
|
+
:note-caption: :paperclip:
|
17
|
+
:tip-caption: :bulb:
|
18
|
+
:warning-caption: :warning:
|
19
|
+
endif::[]
|
20
|
+
|
21
|
+
= Project layout
|
22
|
+
|
23
|
+
Example project structure
|
24
|
+
----
|
25
|
+
project_dir/
|
26
|
+
├── conf
|
27
|
+
│ └── example.yaml
|
28
|
+
├── modules
|
29
|
+
│ └── s3-bucket
|
30
|
+
│ ├── main.tf
|
31
|
+
│ └── variables.tf
|
32
|
+
└── profiles
|
33
|
+
└── example-profile
|
34
|
+
├── data.tf
|
35
|
+
├── main.tf
|
36
|
+
└── variables.tf
|
37
|
+
----
|
38
|
+
|
39
|
+
toc::[]
|
40
|
+
|
41
|
+
== tfctl configuration file
|
42
|
+
|
43
|
+
Assigns Terraform profiles and configuration to accounts based on:
|
44
|
+
|
45
|
+
* Globally for all accounts
|
46
|
+
* Account's organization unit
|
47
|
+
* Individual accounts
|
48
|
+
|
49
|
+
The configuration data is exposed to terraform via a profile `config` variable.
|
50
|
+
|
51
|
+
It also defines Terraform and tfctl configuration such as state tracking and
|
52
|
+
what IAM roles to use.
|
53
|
+
|
54
|
+
== profiles
|
55
|
+
|
56
|
+
Profiles are re-usable collections of resources which can be applied to
|
57
|
+
accounts. They are implemented just like usual modules but provide an
|
58
|
+
intermediate bridge between re-usable modules and tfctl configuration (and/or
|
59
|
+
other data sources). Profiles often compose multiple modules and provide
|
60
|
+
configuration data to them. This approach makes it possible to re-use standard
|
61
|
+
modules (e.g. Terraform module registry).
|
62
|
+
|
63
|
+
== modules
|
64
|
+
|
65
|
+
Standard Terraform modules.
|
@@ -0,0 +1,24 @@
|
|
1
|
+
AWSTemplateFormatVersion: 2010-09-09
|
2
|
+
Description: Configure Terraform execution role in spoke accounts
|
3
|
+
|
4
|
+
Parameters:
|
5
|
+
PrimaryAccountId:
|
6
|
+
Type: String
|
7
|
+
|
8
|
+
Resources:
|
9
|
+
TerraformExecutionRole:
|
10
|
+
Type: AWS::IAM::Role
|
11
|
+
Properties:
|
12
|
+
RoleName: TerraformExecutionRole
|
13
|
+
AssumeRolePolicyDocument:
|
14
|
+
Version: 2012-10-17
|
15
|
+
Statement:
|
16
|
+
- Effect: Allow
|
17
|
+
Principal:
|
18
|
+
AWS:
|
19
|
+
- !Sub 'arn:aws:iam::${PrimaryAccountId}:root'
|
20
|
+
Action:
|
21
|
+
- sts:AssumeRole
|
22
|
+
Path: /
|
23
|
+
ManagedPolicyArns:
|
24
|
+
- arn:aws:iam::aws:policy/AdministratorAccess
|
@@ -0,0 +1,81 @@
|
|
1
|
+
AWSTemplateFormatVersion: 2010-09-09
|
2
|
+
Description: Resources for managing Terraform state
|
3
|
+
|
4
|
+
Parameters:
|
5
|
+
|
6
|
+
PrimaryAccountId:
|
7
|
+
Type: String
|
8
|
+
Description: "Primany account ID"
|
9
|
+
TerraformStateBucket:
|
10
|
+
Type: String
|
11
|
+
Description: "Name of S3 bucket used for storing Terraform state"
|
12
|
+
TerraformDynamoDbTable:
|
13
|
+
Type: String
|
14
|
+
Description: "Name of DynamoDB table used for Terraform state locking"
|
15
|
+
Default: "terraform-lock"
|
16
|
+
|
17
|
+
Resources:
|
18
|
+
|
19
|
+
StateBucket:
|
20
|
+
Type: AWS::S3::Bucket
|
21
|
+
Properties:
|
22
|
+
AccessControl: Private
|
23
|
+
BucketName: !Ref TerraformStateBucket
|
24
|
+
VersioningConfiguration:
|
25
|
+
Status: Enabled
|
26
|
+
BucketEncryption:
|
27
|
+
ServerSideEncryptionConfiguration:
|
28
|
+
- ServerSideEncryptionByDefault:
|
29
|
+
SSEAlgorithm: AES256
|
30
|
+
PublicAccessBlockConfiguration:
|
31
|
+
BlockPublicAcls: True
|
32
|
+
BlockPublicPolicy: True
|
33
|
+
IgnorePublicAcls: True
|
34
|
+
RestrictPublicBuckets: True
|
35
|
+
|
36
|
+
DynamoLockTable:
|
37
|
+
Type: AWS::DynamoDB::Table
|
38
|
+
Properties:
|
39
|
+
TableName: !Ref TerraformDynamoDbTable
|
40
|
+
AttributeDefinitions:
|
41
|
+
- AttributeName: LockID
|
42
|
+
AttributeType: S
|
43
|
+
KeySchema:
|
44
|
+
- AttributeName: LockID
|
45
|
+
KeyType: HASH
|
46
|
+
ProvisionedThroughput:
|
47
|
+
ReadCapacityUnits: 5
|
48
|
+
WriteCapacityUnits: 5
|
49
|
+
|
50
|
+
TerraformStateRole:
|
51
|
+
Type: AWS::IAM::Role
|
52
|
+
Properties:
|
53
|
+
RoleName: TerraformStateRole
|
54
|
+
AssumeRolePolicyDocument:
|
55
|
+
Version: 2012-10-17
|
56
|
+
Statement:
|
57
|
+
- Effect: Allow
|
58
|
+
Principal:
|
59
|
+
AWS:
|
60
|
+
- !Sub 'arn:aws:iam::${PrimaryAccountId}:root'
|
61
|
+
Action:
|
62
|
+
- sts:AssumeRole
|
63
|
+
Path: /
|
64
|
+
Policies:
|
65
|
+
- PolicyName: "terraform-state"
|
66
|
+
PolicyDocument:
|
67
|
+
Version: "2012-10-17"
|
68
|
+
Statement:
|
69
|
+
- Effect: "Allow"
|
70
|
+
Action:
|
71
|
+
- "s3:PutObject"
|
72
|
+
- "s3:GetBucketPolicy"
|
73
|
+
- "s3:GetObject"
|
74
|
+
- "s3:ListBucket"
|
75
|
+
- "dynamodb:PutItem"
|
76
|
+
- "dynamodb:DeleteItem"
|
77
|
+
- "dynamodb:GetItem"
|
78
|
+
Resource:
|
79
|
+
- !GetAtt StateBucket.Arn
|
80
|
+
- !Sub "${StateBucket.Arn}/*"
|
81
|
+
- !GetAtt DynamoLockTable.Arn
|
@@ -0,0 +1,42 @@
|
|
1
|
+
AWSTemplateFormatVersion: 2010-09-09
|
2
|
+
Description: Create Tfctl IAM role used to workout AWS account topolgy
|
3
|
+
|
4
|
+
Parameters:
|
5
|
+
PrimaryAccountId:
|
6
|
+
Type: String
|
7
|
+
Description: Primary organization account ID
|
8
|
+
|
9
|
+
Resources:
|
10
|
+
TfCtlRole:
|
11
|
+
Type: AWS::IAM::Role
|
12
|
+
Properties:
|
13
|
+
RoleName: TfctlRole
|
14
|
+
AssumeRolePolicyDocument:
|
15
|
+
Version: '2012-10-17'
|
16
|
+
Statement:
|
17
|
+
- Effect: Allow
|
18
|
+
Principal:
|
19
|
+
AWS:
|
20
|
+
- !Sub 'arn:aws:iam::${PrimaryAccountId}:root'
|
21
|
+
Action:
|
22
|
+
- sts:AssumeRole
|
23
|
+
Path: /
|
24
|
+
Policies:
|
25
|
+
- PolicyName: TfctlOrgAccess
|
26
|
+
PolicyDocument:
|
27
|
+
Version: '2012-10-17'
|
28
|
+
Statement:
|
29
|
+
- Effect: Allow
|
30
|
+
Action:
|
31
|
+
- organizations:ListAccounts
|
32
|
+
- organizations:ListAccountsForParent
|
33
|
+
- organizations:ListChildren
|
34
|
+
- organizations:ListRoots
|
35
|
+
- organizations:DescribeOrganizationalUnit
|
36
|
+
Resource:
|
37
|
+
- '*'
|
38
|
+
|
39
|
+
Outputs:
|
40
|
+
TfCtlUserArn:
|
41
|
+
Description: tfctl role arn
|
42
|
+
Value: !GetAtt 'TfCtlRole.Arn'
|
@@ -0,0 +1,80 @@
|
|
1
|
+
#
|
2
|
+
# Example Tfctl configuration for AWS Control Tower
|
3
|
+
#
|
4
|
+
# The data in this file is merged with data from AWS Organizations API to
|
5
|
+
# create final configuration used by tfctl. You can view the merged
|
6
|
+
# configuration by running:
|
7
|
+
#
|
8
|
+
# tfctl -c conf/example.yaml -s
|
9
|
+
#
|
10
|
+
|
11
|
+
#
|
12
|
+
# Terraform configuration
|
13
|
+
#
|
14
|
+
|
15
|
+
tf_state_bucket: 'CHANGEME'
|
16
|
+
tf_state_dynamodb_table: 'terraform-lock'
|
17
|
+
tf_state_region: 'eu-west-1'
|
18
|
+
# Role for accessing state resources
|
19
|
+
tf_state_role_arn: 'arn:aws:iam::SHARED_SERVICES_ACCOUNT_ID:role/TerraformStateRole'
|
20
|
+
tf_required_version: '>= 0.12.0'
|
21
|
+
aws_provider_version: '>= 2.14'
|
22
|
+
# Role used by tfctl to retrieve data from AWS Organizations
|
23
|
+
# Has to be set up in the primary org account
|
24
|
+
tfctl_role_arn: 'arn:aws:iam::PRIMARY_ACCOUNT_ID:role/TfctlRole'
|
25
|
+
|
26
|
+
#
|
27
|
+
# Data
|
28
|
+
#
|
29
|
+
# Here you can add arbitrary data which will be accessible from Terraform
|
30
|
+
# profiles. Data can also be defined per account in the organization sections
|
31
|
+
# below.
|
32
|
+
#
|
33
|
+
# data:
|
34
|
+
# my_parameter: some_value
|
35
|
+
|
36
|
+
#
|
37
|
+
# Organization configuration
|
38
|
+
#
|
39
|
+
# Assign resources and data to accounts based on the organization structure.
|
40
|
+
#
|
41
|
+
# IMPORTANT: Removing a Terraform profile here will remove all of it's
|
42
|
+
# associated resources during next apply!
|
43
|
+
|
44
|
+
# Configuration to apply to all accounts
|
45
|
+
organization_root:
|
46
|
+
# Role assumed by Terraform for execution in each account
|
47
|
+
tf_execution_role: 'AWSControlTowerExecution'
|
48
|
+
region: 'eu-west-1'
|
49
|
+
data:
|
50
|
+
# Bucket name used by example profile it will be prefixed with the target
|
51
|
+
# account number for uniqueness across accounts.
|
52
|
+
example_bucket_name: 'tfctl-example-bucket'
|
53
|
+
# Assign example-profile to all accounts in managed OUs
|
54
|
+
profiles:
|
55
|
+
- example-profile
|
56
|
+
|
57
|
+
# Configuration to apply to accounts in Organization Units
|
58
|
+
# OU's not listed here will be ignored.
|
59
|
+
organization_units:
|
60
|
+
# Core: {} # Uncomment if you want to include Core OU accounts
|
61
|
+
live: {}
|
62
|
+
test: {}
|
63
|
+
mgmt:
|
64
|
+
data:
|
65
|
+
# Override the example bucket name in mgmt OU accounts
|
66
|
+
example_bucket_name: 'tfctl-ou-override-example'
|
67
|
+
|
68
|
+
# Configuration to apply to individual accounts
|
69
|
+
account_overrides:
|
70
|
+
test-example1:
|
71
|
+
data:
|
72
|
+
# Override the bucket name in a specific account
|
73
|
+
example_bucket_name: 'tfctl-account-override-example'
|
74
|
+
|
75
|
+
|
76
|
+
# Exclude individual accounts from Terraform runs
|
77
|
+
#
|
78
|
+
# exclude_accounts:
|
79
|
+
# - Audit
|
80
|
+
# - 'Log archive'
|
@@ -0,0 +1 @@
|
|
1
|
+
data "aws_caller_identity" "current" {}
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# This variable must always be present in a profile
|
2
|
+
variable "config" {
|
3
|
+
description = "Configuration generated by tfctl"
|
4
|
+
type = string
|
5
|
+
}
|
6
|
+
|
7
|
+
locals {
|
8
|
+
config = jsondecode(var.config)
|
9
|
+
account_id = "${data.aws_caller_identity.current.account_id}"
|
10
|
+
# get current account configuration from tfctl config
|
11
|
+
account = [ for account in local.config["accounts"]: account if account["id"] == local.account_id ][0]
|
12
|
+
}
|
data/lib/hash.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Add a deep_merge method to a Hash.
|
4
|
+
# It unions arrays (for terraform profiles behaviour)
|
5
|
+
class Hash
|
6
|
+
def deep_merge(second)
|
7
|
+
merger = proc { |_key, v1, v2|
|
8
|
+
if v1.is_a?(Hash) && v2.is_a?(Hash)
|
9
|
+
v1.merge(v2, &merger)
|
10
|
+
elsif v1.is_a?(Array) && v2.is_a?(Array)
|
11
|
+
v1 | v2
|
12
|
+
elsif [:undefined, nil, :nil].include?(v2)
|
13
|
+
v1
|
14
|
+
else
|
15
|
+
v2
|
16
|
+
end
|
17
|
+
}
|
18
|
+
merge(second.to_h, &merger)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Copied from ruby 2.6 Psych for 2.3 compatibility.
|
22
|
+
def symbolize_names!(result = self)
|
23
|
+
case result
|
24
|
+
when Hash
|
25
|
+
result.keys.each do |key|
|
26
|
+
result[key.to_sym] = symbolize_names!(result.delete(key))
|
27
|
+
end
|
28
|
+
when Array
|
29
|
+
result.map! { |r| symbolize_names!(r) }
|
30
|
+
end
|
31
|
+
result
|
32
|
+
end
|
33
|
+
end
|
data/lib/tfctl.rb
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'tfctl/aws_org.rb'
|
4
|
+
require_relative 'tfctl/config.rb'
|
5
|
+
require_relative 'tfctl/error.rb'
|
6
|
+
require_relative 'tfctl/executor.rb'
|
7
|
+
require_relative 'tfctl/generator.rb'
|
8
|
+
require_relative 'tfctl/logger.rb'
|
9
|
+
require_relative 'tfctl/schema.rb'
|
10
|
+
require_relative 'tfctl/version.rb'
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'error.rb'
|
4
|
+
require 'aws-sdk-organizations'
|
5
|
+
|
6
|
+
module Tfctl
|
7
|
+
class AwsOrg
|
8
|
+
|
9
|
+
def initialize(role_arn)
|
10
|
+
@aws_org_client = Aws::Organizations::Client.new(
|
11
|
+
region: 'us-east-1',
|
12
|
+
# Assume role in primary account to read AWS organization API
|
13
|
+
credentials: aws_assume_role(role_arn),
|
14
|
+
)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Gets account data for specified OUs from AWS Organizations API
|
18
|
+
def accounts(org_units)
|
19
|
+
output = { accounts: [] }
|
20
|
+
|
21
|
+
aws_ou_ids = aws_ou_list
|
22
|
+
|
23
|
+
org_units.each do |ou_path|
|
24
|
+
raise Tfctl::Error, "Error: OU: #{ou_path}, does not exists in AWS organization" unless aws_ou_ids.key?(ou_path)
|
25
|
+
|
26
|
+
parent_id = aws_ou_ids[ou_path]
|
27
|
+
|
28
|
+
@aws_org_client.list_accounts_for_parent(parent_id: parent_id).accounts.each do |account|
|
29
|
+
next unless account.status == 'ACTIVE'
|
30
|
+
|
31
|
+
output[:accounts] << {
|
32
|
+
name: account.name,
|
33
|
+
id: account.id,
|
34
|
+
arn: account.arn,
|
35
|
+
email: account.email,
|
36
|
+
ou_path: ou_path.to_s,
|
37
|
+
ou_parents: ou_path.to_s.split('/'),
|
38
|
+
profiles: [],
|
39
|
+
}
|
40
|
+
end
|
41
|
+
end
|
42
|
+
output
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
# Get a mapping of ou_name => ou_id from AWS organizations
|
48
|
+
def aws_ou_list
|
49
|
+
output = {}
|
50
|
+
root_ou_id = @aws_org_client.list_roots.roots[0].id
|
51
|
+
|
52
|
+
ou_recurse = lambda do |ous|
|
53
|
+
ous.each do |ou_name, ou_id|
|
54
|
+
children = aws_ou_list_children(ou_id, ou_name)
|
55
|
+
unless children.empty?
|
56
|
+
output.merge!(children)
|
57
|
+
ou_recurse.call(children)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
ou_recurse.call(root: root_ou_id)
|
62
|
+
|
63
|
+
output
|
64
|
+
end
|
65
|
+
|
66
|
+
# Get a list of child ou's for a parent
|
67
|
+
def aws_ou_list_children(parent_id, parent_name)
|
68
|
+
output = {}
|
69
|
+
retries = 0
|
70
|
+
|
71
|
+
@aws_org_client.list_children(
|
72
|
+
child_type: 'ORGANIZATIONAL_UNIT',
|
73
|
+
parent_id: parent_id,
|
74
|
+
).children.each do |child|
|
75
|
+
|
76
|
+
begin
|
77
|
+
ou = @aws_org_client.describe_organizational_unit(
|
78
|
+
organizational_unit_id: child.id,
|
79
|
+
).organizational_unit
|
80
|
+
rescue Aws::Organizations::Errors::TooManyRequestsException
|
81
|
+
# FIXME: - use logger
|
82
|
+
puts 'AWS Organizations: too many requests. Retrying in 5 secs.'
|
83
|
+
sleep 5
|
84
|
+
retries += 1
|
85
|
+
retry if retries < 10
|
86
|
+
end
|
87
|
+
|
88
|
+
ou_name = parent_name == :root ? ou.name.to_sym : "#{parent_name}/#{ou.name}".to_sym
|
89
|
+
|
90
|
+
output[ou_name] = ou.id
|
91
|
+
end
|
92
|
+
output
|
93
|
+
end
|
94
|
+
|
95
|
+
def aws_assume_role(role_arn)
|
96
|
+
begin
|
97
|
+
sts = Aws::STS::Client.new
|
98
|
+
|
99
|
+
role_credentials = Aws::AssumeRoleCredentials.new(
|
100
|
+
client: sts,
|
101
|
+
role_arn: role_arn,
|
102
|
+
role_session_name: 'tfctl',
|
103
|
+
)
|
104
|
+
rescue StandardError => e
|
105
|
+
raise Tfctl::Error, "Error assuming role: #{role_arn}, #{e.message}"
|
106
|
+
end
|
107
|
+
|
108
|
+
role_credentials
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
112
|
+
end
|