はじめに
Identity Centerで管理されていないユーザーのアクセス管理について、皆さんはどうされていますか?
弊社では、Identity Centerで管理されていないユーザーが安全に弊社のAWSアカウントへアクセスできるよう、JUMPアカウントを使用したログイン体制を整備しました。このJUMPアカウントはAWS CDKを使って自動的に作成され、効率的に管理できる仕組みになっています。
この記事では、JUMPアカウントの構成方法と、AWS CDKを使ってどのように実装したかをご紹介します。
また、前編となる下記の記事も見ていただければと思います。
JUMPアカウント作成の目的
現在、弊社ではMicrosoft Entra IDとIdentity Centerを活用し、各アカウントへのSSO(シングルサインオン)を実現しています。しかし、中には契約面の関係でEntra IDからユーザーを払い出していない作業者(SE)もいるため、Identity Centerを通じたログイン制御が適用できない状況です。
この対応策として、Entra IDを持たないユーザー向けに「JUMPアカウント」を設定し、IAMユーザーの一括管理を行う仕組みを整備しました。これにより、そのようなユーザーにおいても、安全かつ効率的なアクセス管理が可能になりました。
要件
安全かつ効率的にJUMPアカウントを設定するために以下のように要件を設定しました。
- IAMユーザーはJUMPアカウントにのみ作成、一元管理とする。
- パートナーごとに大枠グループを分け、権限管理しやすく、かつわかりやすくする。
- パートナー内で違う権限セットが必要な場合は、連番でグループを分ける。
- スイッチ用ロールはJUMPアカウントからCloudFormationのStacksetsを使用して作成、一元管理する。
- MFA認証を必須とする。
MFA認証は、セキュリティのベストプラクティスなので必須としました
IAM でのセキュリティのベストプラクティス(多要素認証 (MFA) を必須とする)
構成
ユーザーはJUMPアカウントにMFAを持つIAMユーザーを所有する。そのIAMユーザーを使用し、各アカウントへスイッチロールすることでログインできるようにしました。
環境
使用している環境は以下となります。
$ node -v
v18.19.0
$ cdk --version
2.125.0 (build 5e3c3c6)
ファイル構成
CMN-JUMP
├── bin
│ └── cmn-jump.ts # エントリーポイント
├── lib
│ ├── constants
│ │ └── vendor.ts # パートナー毎のIAM設定
│ ├── constructs
│ │ ├── iam.ts # JUMPアカウントのIAM定義
│ │ └── service-managed-stacksets.ts # サービスマネージド型Stacksets定義
│ ├── templates
│ │ └── iam.ts # SwitchRole先のIAM設定
│ ├── types
│ │ ├── account.ts # AWSアカウント設定
│ │ └── common.ts # 共通定数
│ └── cmn-jump-stack.ts # JUMPアカウントのCDKスタック定義
└── README.md
各リソースを記述
cmn-jump.ts
エントリーポイントとなり、パートナー毎にStackを作成します。
タグは弊社内ルールに基づいて付与しています。
複数パートナーが存在する、かつ権限変更がある程度の頻度で発生するのでパートナーごとにStackを分けました
#!/usr/bin/env node
import 'source-map-support/register';
import { App, Tags } from 'aws-cdk-lib';
import { CmnJumpStack } from '../lib/cmn-jump-stack';
import { vendors } from '../lib/constants/vendor';
import { DirectAccountId, systemName } from '../lib/types/common';
const app = new App();
vendors.forEach((vendor, i) => {
const vendorName = vendor.name.replace(/[-_]/g, '');
new CmnJumpStack(app, `${systemName}-${vendorName}-role-stack`, {
description: 'create stackset to create roles for cross-account access',
env: {
account: DirectAccountId.JUMP,
region: 'ap-northeast-1',
},
vendor: vendor,
terminationProtection: true,
});
});
/**
* Tags
*/
Tags.of(app).add('SystemName', systemName);
Tags.of(app).add('Environment', 'prd');
Tags.of(app).add('Owner', 'CCoE');
cmn-jump-stack.ts
ここでは、StacksetsでSwitchRole先に流すStacksetsテンプレートの作成と、そのテンプレートを使用しRoleを作成するStacksetsの設定、JUMPアカウント内のIAMを設定します。
import { CfnTag, DefaultStackSynthesizer, Stack, StackProps, Stage } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Iam } from './constructs/iam';
import { ServiceManagedStackSets } from './constructs/service-managed-stacksets';
import { IamStack } from './templates/iam';
import { accounts } from './types/accounts';
import { DirectAccountId, Vendor, systemName } from './types/common';
interface CmnJumpStackProps extends StackProps {
vendor: Vendor;
}
export class CmnJumpStack extends Stack {
constructor(scope: Construct, id: string, props: CmnJumpStackProps) {
super(scope, id, props);
const regions = ['ap-northeast-1'];
props.vendor.stackSetsInfos.forEach((stackSetsInfo, i) => {
const account = accounts.find((acc) => acc.id === stackSetsInfo.account)!;
const stage = new Stage(
this,
`Stage-${props.vendor.name}-${account.alias}`
);
new IamStack(stage, 'IamStack', {
synthesizer: new DefaultStackSynthesizer({
generateBootstrapVersionRule: false,
}),
vendorName: props.vendor.name,
assumeRoleSourceAccountId: DirectAccountId.JUMP,
});
const templateBody = JSON.stringify(stage.synth().stacks[0].template);
new ServiceManagedStackSets(
this,
`ServiceManagedStackSets${account.id}`,
{
stackSetsInfo: stackSetsInfo,
accountInfo: account,
regions: regions,
templateBody: templateBody,
vendorName: props.vendor.name,
}
);
});
new Iam(this, 'Iam', {
vendorName: props.vendor.name,
groupInfos: props.vendor.groupInfos,
});
}
}
iam.ts(JUMPアカウント内のIAM設定用)
MFA認証を必須とするポリシー(enforceMfaPolicy)と、vendor.ts で定義されたユーザーとグループに基づいてIAMを作成します。
import { aws_iam as iam, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { AccountAuthMap, GroupInfo } from '../types/common';
export interface IamProps {
vendorName: string;
groupInfos: GroupInfo[];
}
export class Iam extends Construct {
private readonly vendorName: string;
constructor(scope: Construct, id: string, props: IamProps) {
super(scope, id);
this.vendorName = props.vendorName;
const enforceMfaPolicyStatements: iam.PolicyStatement[] = [
new iam.PolicyStatement({
sid: 'AllowViewAccountInfo',
effect: iam.Effect.ALLOW,
actions: ['iam:GetAccountPasswordPolicy', 'iam:ListVirtualMFADevices'],
resources: ['*'],
}),
new iam.PolicyStatement({
sid: 'AllowManageOwnPasswords',
effect: iam.Effect.ALLOW,
actions: ['iam:ChangePassword', 'iam:GetUser'],
resources: ['arn:aws:iam::*:user/${aws:username}'],
}),
new iam.PolicyStatement({
sid: 'AllowManageOwnAccessKeys',
effect: iam.Effect.ALLOW,
actions: [
'iam:CreateAccessKey',
'iam:DeleteAccessKey',
'iam:ListAccessKeys',
'iam:UpdateAccessKey',
'iam:GetAccessKeyLastUsed',
],
resources: ['arn:aws:iam::*:user/${aws:username}'],
}),
new iam.PolicyStatement({
sid: 'AllowManageOwnSigningCertificates',
effect: iam.Effect.ALLOW,
actions: [
'iam:DeleteSigningCertificate',
'iam:ListSigningCertificates',
'iam:UpdateSigningCertificate',
'iam:UploadSigningCertificate',
],
resources: ['arn:aws:iam::*:user/${aws:username}'],
}),
new iam.PolicyStatement({
sid: 'AllowManageOwnSSHPublicKeys',
effect: iam.Effect.ALLOW,
actions: [
'iam:DeleteSSHPublicKey',
'iam:GetSSHPublicKey',
'iam:ListSSHPublicKeys',
'iam:UpdateSSHPublicKey',
'iam:UploadSSHPublicKey',
],
resources: ['arn:aws:iam::*:user/${aws:username}'],
}),
new iam.PolicyStatement({
sid: 'AllowManageOwnGitCredentials',
effect: iam.Effect.ALLOW,
actions: [
'iam:CreateServiceSpecificCredential',
'iam:DeleteServiceSpecificCredential',
'iam:ListServiceSpecificCredentials',
'iam:ResetServiceSpecificCredential',
'iam:UpdateServiceSpecificCredential',
],
resources: ['arn:aws:iam::*:user/${aws:username}'],
}),
new iam.PolicyStatement({
sid: 'AllowManageOwnVirtualMFADevice',
effect: iam.Effect.ALLOW,
actions: ['iam:CreateVirtualMFADevice', 'iam:DeleteVirtualMFADevice'],
resources: ['arn:aws:iam::*:mfa/*'],
}),
new iam.PolicyStatement({
sid: 'AllowManageOwnUserMFA',
effect: iam.Effect.ALLOW,
actions: [
'iam:DeactivateMFADevice',
'iam:EnableMFADevice',
'iam:ListMFADevices',
'iam:ResyncMFADevice',
],
resources: ['arn:aws:iam::*:user/${aws:username}'],
}),
new iam.PolicyStatement({
sid: 'DenyAllExceptListedIfNoMFA',
effect: iam.Effect.DENY,
notActions: [
'iam:CreateVirtualMFADevice',
'iam:EnableMFADevice',
'iam:GetUser',
'iam:GetMFADevice',
'iam:ListMFADevices',
'iam:ListVirtualMFADevices',
'iam:ResyncMFADevice',
'sts:GetSessionToken',
],
resources: ['*'],
conditions: {
BoolIfExists: {
'aws:MultiFactorAuthPresent': 'false',
},
},
}),
];
const enforceMfaPolicy = new iam.ManagedPolicy(this, 'EnforceMfaPolicy', {
managedPolicyName: `${this.vendorName}-enforce-mfa-policy`,
statements: enforceMfaPolicyStatements,
});
props.groupInfos.forEach((groupInfo, i) => {
i += 1;
const seqNum = i.toString().padStart(3, '0');
const group = new iam.Group(this, `Group${seqNum}`, {
groupName: `${this.vendorName}-${seqNum}-group`,
});
new iam.ManagedPolicy(this, `AssumeRolePolicy${seqNum}`, {
managedPolicyName: `${this.vendorName}-${seqNum}-policy`,
groups: [group],
statements: [this.getStatement(groupInfo.accountAuthMaps)],
});
enforceMfaPolicy.attachToGroup(group);
if (groupInfo.users) {
groupInfo.users.forEach((user) => {
const iamuser = new iam.User(
this,
`User${this.createIamUserLogicalIdFromEmail(user)}`,
{
userName: user,
}
);
iamuser.applyRemovalPolicy(RemovalPolicy.RETAIN);
iamuser.addToGroup(group);
});
}
});
}
protected getStatement(
accountAuthMaps: AccountAuthMap[]
): iam.PolicyStatement {
let resources: string[] = [];
accountAuthMaps.forEach((accountAuthMap) => {
accountAuthMap.authTypes.forEach((authType) => {
resources.push(
`arn:aws:iam::${accountAuthMap.account}:role/${this.vendorName}-${authType}-role`
);
});
});
const policyStatement = new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['sts:AssumeRole'],
resources: resources,
});
return policyStatement;
}
protected createIamUserLogicalIdFromEmail(email: string): string {
let localPart = email.substring(0, email.indexOf('@'));
localPart = localPart.replace(/[-_.]/g, '');
const iamUserLogicalIdPascalCase =
localPart.charAt(0).toUpperCase() + localPart.slice(1);
return iamUserLogicalIdPascalCase;
}
}
service-managed-stacksets.ts
サービスマネージド型のStacksetsの作成します。
import { CfnStackSet, CfnTag } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Account, StackSetsInfo, systemName } from '../types/common';
interface ServiceManagedStackSetsProps {
stackSetsInfo: StackSetsInfo;
accountInfo: Account;
regions: string[];
templateBody: string;
tags: CfnTag[];
vendorName: string;
}
export class ServiceManagedStackSets extends Construct {
constructor(
scope: Construct,
id: string,
props: ServiceManagedStackSetsProps
) {
super(scope, id);
new CfnStackSet(this, 'Default', {
stackSetName: `${systemName}-${props.vendorName}-role-for-${props.accountInfo.alias}-stackset`,
permissionModel: 'SERVICE_MANAGED',
capabilities: ['CAPABILITY_NAMED_IAM'],
autoDeployment: {
enabled: false,
},
stackInstancesGroup: [
{
regions: props.regions,
deploymentTargets: {
organizationalUnitIds: [props.accountInfo.ouId!],
accounts: [props.accountInfo.id],
accountFilterType: 'INTERSECTION',
},
},
],
callAs: 'DELEGATED_ADMIN',
templateBody: props.templateBody,
});
}
}
iam.ts(スイッチ先ロール作成用)
基本的な権限として以下4つを使用有無に関わらず作成します。
- Administrator
- Power+IAMFull
- Power
- ReadOnly
また、各ロールはカスタム設定できるようにパートナー毎に作成します。
例){パートナー名}-admin-role
import { aws_iam as iam, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { AuthType, DirectAccountId, ResellerAccountId } from '../types/common';
interface IamStackProps extends StackProps {
vendorName: string;
assumeRoleSourceAccountId: DirectAccountId | ResellerAccountId;
}
export class IamStack extends Stack {
private assumeRoleSourceAccountId: DirectAccountId | ResellerAccountId;
constructor(scope: Construct, id: string, props: IamStackProps) {
super(scope, id, props);
this.assumeRoleSourceAccountId = props.assumeRoleSourceAccountId;
// AuthType: admin
const adminRole = new iam.Role(this, 'AdministratorAccessRole', {
roleName: `${props.vendorName}-${AuthType.Admin}-role`,
assumedBy: new iam.AccountPrincipal(this.assumeRoleSourceAccountId),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('AdministratorAccess'),
],
});
// AuthType: power
const powerRole = new iam.Role(this, 'PowerUserAccessRole', {
roleName: `${props.vendorName}-${AuthType.Power}-role`,
assumedBy: new iam.AccountPrincipal(this.assumeRoleSourceAccountId),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('PowerUserAccess'),
iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess'),
],
});
// AuthType: read
const readRole = new iam.Role(this, 'ReadOnlyAccessRole', {
roleName: `${props.vendorName}-${AuthType.Read}-role`,
assumedBy: new iam.AccountPrincipal(this.assumeRoleSourceAccountId),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess'),
iam.ManagedPolicy.fromAwsManagedPolicyName('AWSCloudShellFullAccess'),
],
});
// AuthType: power-iam
const powerIamRole = new iam.Role(this, 'PowerUserAndIamFullAccessRole', {
roleName: `${props.vendorName}-${AuthType.PowerAndIam}-role`,
assumedBy: new iam.AccountPrincipal(this.assumeRoleSourceAccountId),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('PowerUserAccess'),
iam.ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess'),
iam.ManagedPolicy.fromAwsManagedPolicyName('IAMFullAccess'),
],
});
}
}
account.ts
AWSアカウントデータをリスト型で管理します。
他ファイルで使用するAWSアカウントデータはこのファイルから取得します。
項目名 | 概要 | 詳細 |
---|---|---|
id | アカウントID | common.tsのアカウント番号一覧を参照する |
alias | アカウントエイリアス | アカウントエイリアスを小文字のスネークキャメルケースで表現する |
type | アカウントタイプ(自己所有 or リセラーアカウント) | common.tsのアカウントタイプを参照する |
ouId | ControlTowerのOrganizationID | common.tsのOU-IDの一覧を参照する |
export const accounts: Account[] = [
{
id: DirectAccountId.AAA,
alias: 'BBBB',
type: AccountType.DIRECT,
ouId: OuId.XXX,
},
{
...
]
vendor.ts
パートナーデータを管理します。
このファイルに基づき、IAMユーザー、グループ、権限設定を行います。
パートナー情報は以下のように構成しています。
項目名 | 概要 | 詳細 |
---|---|---|
name | パートナー会社名 | |
stackSetsInfo | stacksetsデータ | 作成するStacksets情報 |
stackSetsInfo.account | アカウント | Stacksetsを展開するアカウント |
groupInfo | グループ情報 | 設定するグループの情報 |
groupInfo.users | ユーザー情報 | グループに追加するIAMユーザーのリスト |
groupInfo.accountAuthMaps | 権限セット | グループの権限情報 |
groupInfo.accountAuthMaps.account | スイッチ先アカウント | スイッチ先のアカウント |
groupInfo.accountAuthMaps.authTypes | 権限タイプ | スイッチ先の権限タイプのリスト |
export const vendors: Vendor[] = [];
vendor.push({
name: 'vendor1'
stackSetsInfo: [
{
account: DirectAccountId.AAA,
}
],
groupInfo: [
{
users: [ 'user1@example.com' ],
accountAuthMaps: [
{
account: DirectAccountId.AAA,
authtype: [Authtype.Admin]
}
]
}
]
})
デプロイ
テストデータを使ってデプロイしてみました。
vendors.push({
name: 'test',
stackSetsInfos: [
{
account: DirectAccountId.AAA,
},
],
groupInfos: [
{
users: ['test@example.com'],
accountAuthMaps: [
{
account: DirectAccountId.AAA,
authTypes: [AuthType.Read],
},
],
},
],
});
JUMPアカウントにIAMユーザーとグループが作成されました!
IAMユーザー
無事、指定したアカウントにスイッチロールすることができました。
まとめ
今回、AWS CDKを使用してJUMPアカウントを構成しました。コードで管理することで、マネジメントコンソール上での作業をほとんど不要とし、管理のしやすさと作業効率の向上を実現することができました。
また、新たな環境が増えた場合でもすぐに対応できるため、柔軟かつスケーラブルな運用が可能になりました。
この記事を機に、CDK・JUMPアカウントを使用してみてはいかがでしょうか?
弊社では一緒に働く仲間を募集中です!
現在、様々な職種を募集しております。
カジュアル面談も可能ですので、ご連絡お待ちしております!
募集内容等詳細は、是非採用サイトをご確認ください。