diff --git a/CHANGELOG.md b/CHANGELOG.md index 7adb867c..219a3d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,18 @@ The format is based on [Keep a Changelog], and this project adheres to ## [Unreleased] -[Unreleased]: https://github.com/envato/stack_master/compare/v2.16.0...HEAD +[Unreleased]: https://github.com/envato/stack_master/compare/v2.17.0...HEAD + +## [2.17.0] - 2025-07-11 + +### Added + +- Add a parameter resolver for AWS SSO/IIC mapping group display name to id ([#390]) + +```yaml +group_id: + sso_group_id: "us-east-1:d-123456bf8/SSO Group Display Name" +``` ## [2.16.0] - 2024-08-01 diff --git a/README.md b/README.md index 04974de8..e6cc62a1 100644 --- a/README.md +++ b/README.md @@ -416,6 +416,24 @@ ssh_sg: - WebAccessSecurityGroup ``` +### AWS IIC/SSO Group IDs + +Looks up AWS Identity Center group name in the configured Identity Store and returns the ID suitable for use in AWS IIC assignments. +It is likely that account and role will need to be specified to do the lookup, the region specification is optional it defaults to stack region. + +```yaml +GroupId: + sso_group_id: '[region:]identity-store-id/SSO Group Name' +``` + +e.g. +```yaml +GroupIdNotInStackRegion: + sso_group_id: 'us-east-1:d-123456df8:Okta-App-AWS-FooBar' +GroupIdInStackRegion: + sso_group_id: 'd-123456df8:Okta-App-AWS-FooBar' +``` + ### SNS Topic Looks up an SNS topic by name and returns the ARN. diff --git a/lib/stack_master.rb b/lib/stack_master.rb index bdc16996..4234330d 100644 --- a/lib/stack_master.rb +++ b/lib/stack_master.rb @@ -4,6 +4,7 @@ require 'aws-sdk-cloudformation' require 'aws-sdk-ec2' require 'aws-sdk-ecr' +require 'aws-sdk-identitystore' require 'aws-sdk-s3' require 'aws-sdk-sns' require 'aws-sdk-ssm' @@ -33,6 +34,7 @@ module StackMaster autoload :StackStatus, 'stack_master/stack_status' autoload :SnsTopicFinder, 'stack_master/sns_topic_finder' autoload :SecurityGroupFinder, 'stack_master/security_group_finder' + autoload :SsoGroupIdFinder, 'stack_master/sso_group_id_finder' autoload :ParameterLoader, 'stack_master/parameter_loader' autoload :ParameterResolver, 'stack_master/parameter_resolver' autoload :RoleAssumer, 'stack_master/role_assumer' @@ -84,6 +86,7 @@ module ParameterResolvers autoload :Ejson, 'stack_master/parameter_resolvers/ejson' autoload :SnsTopicName, 'stack_master/parameter_resolvers/sns_topic_name' autoload :SecurityGroup, 'stack_master/parameter_resolvers/security_group' + autoload :SsoGroupId, 'stack_master/parameter_resolvers/sso_group_id' autoload :LatestAmiByTags, 'stack_master/parameter_resolvers/latest_ami_by_tags' autoload :LatestAmi, 'stack_master/parameter_resolvers/latest_ami' autoload :Env, 'stack_master/parameter_resolvers/env' diff --git a/lib/stack_master/parameter_resolvers/sso_group_id.rb b/lib/stack_master/parameter_resolvers/sso_group_id.rb new file mode 100644 index 00000000..2f9ebc44 --- /dev/null +++ b/lib/stack_master/parameter_resolvers/sso_group_id.rb @@ -0,0 +1,21 @@ +module StackMaster + module ParameterResolvers + class SsoGroupId < Resolver + InvalidParameter = Class.new(StandardError) + + def initialize(config, stack_definition) + @config = config + @stack_definition = stack_definition + end + + def resolve(value) + sso_group_id_finder.find(value) + end + + private + def sso_group_id_finder + StackMaster::SsoGroupIdFinder.new() + end + end + end +end diff --git a/lib/stack_master/sso_group_id_finder.rb b/lib/stack_master/sso_group_id_finder.rb new file mode 100644 index 00000000..c2a32434 --- /dev/null +++ b/lib/stack_master/sso_group_id_finder.rb @@ -0,0 +1,33 @@ +module StackMaster + class SsoGroupIdFinder + SsoGroupNotFound = Class.new(StandardError) + + def find(reference) + output_regex = %r{(?:(?[^:]+):)?(?[^:/]+)/(?.+)} + + if !reference.is_a?(String) || !(match = output_regex.match(reference)) + raise ArgumentError, 'Sso group lookup parameter must be in the form of [region:]identity-store-id/group_name' + end + + region = match[:region] || StackMaster.cloud_formation_driver.region + client = Aws::IdentityStore::Client.new({ region: region }) + + begin + response = client.get_group_id({ + identity_store_id: match[:identity_store_id], + alternate_identifier: { + unique_attribute: { + attribute_path: 'displayName', + attribute_value: match[:group_name], + }, + }, + }) + return response.group_id + rescue Aws::IdentityStore::Errors::ServiceError => e + puts "Error calling GetGroupId: #{e.message}" + end + + raise SsoGroupNotFound, "No group with name #{match[:group_name]} found in identity store #{match[:identity_store_id]} in #{region}" + end + end +end diff --git a/lib/stack_master/version.rb b/lib/stack_master/version.rb index 1fba44ac..92d0bac8 100644 --- a/lib/stack_master/version.rb +++ b/lib/stack_master/version.rb @@ -1,3 +1,3 @@ module StackMaster - VERSION = "2.16.0" + VERSION = "2.17.0" end diff --git a/spec/stack_master/parameter_resolvers/sso_group_id_spec.rb b/spec/stack_master/parameter_resolvers/sso_group_id_spec.rb new file mode 100644 index 00000000..abc7d518 --- /dev/null +++ b/spec/stack_master/parameter_resolvers/sso_group_id_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +RSpec.describe StackMaster::ParameterResolvers::SsoGroupId do + let(:config) { instance_double('Config') } + let(:stack_definition) { instance_double('StackDefinition', region: 'us-east-1') } + + subject(:resolver) { described_class.new(config, stack_definition) } + + let(:group_reference) { 'us-east-1:d-12345678/AdminGroup' } + let(:resolved_group_id) { 'abc-123-group-id' } + let(:finder) { instance_double(StackMaster::SsoGroupIdFinder) } + + before do + allow(StackMaster::SsoGroupIdFinder).to receive(:new).and_return(finder) + end + + describe '#resolve' do + context 'when group is found' do + it 'returns the resolved group ID' do + expect(finder).to receive(:find).with(group_reference).and_return(resolved_group_id) + + result = resolver.resolve(group_reference) + expect(result).to eq(resolved_group_id) + end + end + + context 'when SsoGroupIdFinder raises an error' do + it 'propagates the SsoGroupNotFound error' do + allow(finder).to receive(:find).and_raise(StackMaster::SsoGroupIdFinder::SsoGroupNotFound) + + expect { + resolver.resolve(group_reference) + }.to raise_error(StackMaster::SsoGroupIdFinder::SsoGroupNotFound) + end + end + + context 'with invalid input' do + let(:invalid_reference) { 'not/a/valid/reference' } + + it 'raises ArgumentError from SsoGroupIdFinder' do + allow(finder).to receive(:find).and_raise(ArgumentError) + + expect { + resolver.resolve(invalid_reference) + }.to raise_error(ArgumentError) + end + end + end +end + diff --git a/spec/stack_master/sso_group_id_finder_spec.rb b/spec/stack_master/sso_group_id_finder_spec.rb new file mode 100644 index 00000000..7a34db74 --- /dev/null +++ b/spec/stack_master/sso_group_id_finder_spec.rb @@ -0,0 +1,95 @@ +require 'spec_helper' + +RSpec.describe StackMaster::SsoGroupIdFinder do + let(:group_name) { 'AdminGroup' } + let(:identity_store_id) { 'd-12345678' } + let(:region) { 'us-east-1' } + let(:reference) { "#{region}:#{identity_store_id}/#{group_name}" } + let(:aws_client) { instance_double(Aws::IdentityStore::Client) } + + subject(:finder) do + allow(Aws::IdentityStore::Client).to receive(:new).with({region: region}).and_return(aws_client) + described_class.new + end + + before do + allow(StackMaster).to receive(:cloud_formation_driver).and_return(double(region: region)) + end + + describe '#find' do + context 'when the group is found successfully' do + it 'returns the group ID' do + group_id = 'abc-123-group-id' + + response = double(group_id: group_id) + expect(aws_client).to receive(:get_group_id).with({ + identity_store_id: identity_store_id, + alternate_identifier: { + unique_attribute: { + attribute_path: 'displayName', + attribute_value: group_name + } + } + }).and_return(response) + + expect(finder.find(reference)).to eq(group_id) + end + end + + context 'when the group is not found' do + it 'raises SsoGroupNotFound' do + error = Aws::IdentityStore::Errors::ResourceNotFoundException.new( + Seahorse::Client::RequestContext.new, + "Group not found" + ) + + expect(aws_client).to receive(:get_group_id).and_raise(error) + + expect { + finder.find(reference) + }.to raise_error(StackMaster::SsoGroupIdFinder::SsoGroupNotFound, /No group with name #{group_name} found/) + end + end + + context 'when region is not provided in reference' do + let(:reference_without_region) { "#{identity_store_id}/#{group_name}" } + + it 'uses the fallback region from cloud_formation_driver' do + allow(Aws::IdentityStore::Client).to receive(:new).with({region: region}).and_return(aws_client) + + group_id = 'fallback-region-group-id' + response = double(group_id: group_id) + + expect(aws_client).to receive(:get_group_id).with({ + identity_store_id: identity_store_id, + alternate_identifier: { + unique_attribute: { + attribute_path: 'displayName', + attribute_value: group_name + } + } + }).and_return(response) + + expect(finder.find(reference_without_region)).to eq(group_id) + end + end + + context 'when input is not a string' do + it 'raises ArgumentError' do + expect { + finder.find(123) + }.to raise_error(ArgumentError, /Sso group lookup parameter must be in the form/) + end + end + + context 'when input is an invalid string' do + it 'raises ArgumentError' do + invalid_reference = 'badformat' + + expect { + finder.find(invalid_reference) + }.to raise_error(ArgumentError, /Sso group lookup parameter must be in the form/) + end + end + end +end diff --git a/stack_master.gemspec b/stack_master.gemspec index 5d803c09..00f60a28 100644 --- a/stack_master.gemspec +++ b/stack_master.gemspec @@ -40,6 +40,7 @@ Gem::Specification.new do |spec| spec.add_dependency "aws-sdk-acm", "~> 1" spec.add_dependency "aws-sdk-cloudformation", "~> 1" spec.add_dependency "aws-sdk-ec2", "~> 1" + spec.add_dependency "aws-sdk-identitystore", "~> 1" spec.add_dependency "aws-sdk-s3", "~> 1" spec.add_dependency "aws-sdk-sns", "~> 1" spec.add_dependency "aws-sdk-ssm", "~> 1"