SCP本番1年半で発見した、IAMとの組み合わせで権限が機能しない7つのケース
AWSのSCP運用で「設定したはずなのに機能していない」という落とし穴に気づいた話。IAMとの相互作用で陥りやすいデニーリストの失敗パターンを実体験から解説します。
うちのチームがSCPで権限地獄に陥った話
先日、チームの権限周りを棚卸ししていてぞっとした。SCPを導入して1年半運用してるのに、実は機能してないポリシーが7個も存在していたんだ。最初は「セキュリティを強化するぞ」って張り切ってたんだけど、IAMポリシーとの組み合わせで想像と違う挙動をしてて、ずっと気づかなかったんだよ。
SCPについて正直に書きたい。教科書的な「ガードレール」という説明は半分正しいけど、実装の落とし穴は本当に地味で重い。特にIAMとの相互作用は、公式ドキュメント読むだけだと絶対分からない部分がある。うちのチームが1年半かけて学んだこと、シェアしておく。
SCPとIAMの「本当の関係」—デニーリストとアローリストの共存地獄
最初、僕たちはSCPをシンプルに考えてた。「IAMポリシーの上位層」「全体的なガードレール」みたいな感じ。でも実際に本番環境で運用したら、思った以上に複雑だった。
特に痛感したのは、デニーリスト型のSCPとアローリスト型IAMポリシーの組み合わせだ。例えば、うちが書いたSCPがこれなんだけど。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Action": [
"ec2:TerminateInstances",
"ec2:DeleteSnapshot",
"s3:DeleteBucket"
],
"Resource": "*",
"Condition": {
"StringNotEquals": {
"aws:PrincipalOrgID": "o-xxxxxxxxxx"
}
}
}
]
}
シンプルだ。「危険な操作は許可しない」という単純な考え方。でも問題は、IAM側でアローリストを使ってるときに起きる。IAMで「ec2:*は許可する」って書いてあると、SCPの効き方が予測不可能になるんだよ。
正確には、IAMで許可されてるのに、SCPでDenyされてれば、その操作は失敗する。これ自体は理に適ってるんだけど、問題は権限周りのトラブルシューティングのときに「どっちで止められたのか」が見分けにくいこと。CloudTrailを見ても、SCP由来のDenyとIAM由来のAccessDeniedが同じに見える。
UserInitiatedEvent: ec2:TerminateInstances
ErrorCode: AccessDenied
ErrorMessage: User: arn:aws:iam::123456789:user/dev is not authorized to perform: ec2:TerminateInstances on resource: arn:aws:ec2:...
このCloudTrailログだけ見たら、IAMで拒否されたのか、SCPで拒否されたのか、本気で分からない。調査に2時間費やしたよ。
データドリブンなSCP設計—本当に必要な規制と過度な規制の線引き
1年半運用してみて気づいたことは、SCPは「禁止」じゃなく「許可される範囲の定義」と考えるべきってことだ。デニーリストとアローリストの考え方を整理した方が、運用はずっと楽になる。
うちのチームが落ち着いた設計は、以下の3層構造。
flowchart TB
A[Organization Root] --> B[SCP: 最小限のDeny]
B --> C["✅ 危険なサービスは絶対に禁止"]
B --> D["✅ 権限昇格のような操作は禁止"]
A --> E[Account-level IAM]
E --> F["✅ ロールベースの許可"]
E --> G["✅ タグベースの条件付き許可"]
A --> H[Service Control Policies]
H --> I["Account SCP: より詳細な制御"]
style B fill:#e1f5ff
style E fill:#f3e5f5
style H fill:#e8f5e9
この設計の肝は、SCPは「この組織内では絶対にこれはやらない」という最小限の制約に限定することだ。それ以外の権限制御はIAMで精密にやる。
実際に、うちが本当に必要だったSCPはこれくらい。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PreventIAMRootActions",
"Effect": "Deny",
"Action": [
"iam:DeleteUser",
"iam:DeleteRole",
"iam:PutUserPolicy",
"iam:AttachUserPolicy",
"iam:CreateAccessKey"
],
"Resource": [
"arn:aws:iam::*:role/RootRole",
"arn:aws:iam::*:user/root"
]
},
{
"Sid": "PreventDisablingCloudTrail",
"Effect": "Deny",
"Action": [
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail"
],
"Resource": "*"
},
{
"Sid": "PreventSCPBypass",
"Effect": "Deny",
"Action": [
"organizations:DetachPolicy",
"organizations:DeletePolicy"
],
"Resource": "*"
}
]
}
「最小限」という表現が大事なんだよ。本当に「誰が何をしてもやってはいけない」ことだけを書いた。開発環境でのEC2削除制限とか、そういう「運用的なガイドライン」的なものはSCPには書かない。それはIAMロールで実装する方が、よっぽど融通がきく。
AWSの構成図で見たSCP実装アーキテクチャ
うちのマルチアカウント環境では、こんな構成でSCPを運用してる。
graph TB
subgraph Organization["AWS Organization"]
Root["Organization Root<br/>SCPs: Baseline"]
Root --> OU_Dev["OU: Development<br/>SCP: Dev制限"]
Root --> OU_Prod["OU: Production<br/>SCP: Prod制限"]
Root --> OU_Sec["OU: Security<br/>SCP: 最小限制"]
OU_Dev --> DevAcc1["Dev Account A<br/>IAM: 自由度高"]
OU_Dev --> DevAcc2["Dev Account B<br/>IAM: 自由度高"]
OU_Prod --> ProdAcc["Prod Account<br/>IAM: 厳格"]
OU_Sec --> SecAcc["Security Account<br/>IAM: 最小権限"]
end
Root -->|CloudTrail Logs| CloudTrail[("CloudTrail<br/>Central Logging")]
Root -->|SCP Events| CloudTrail
ProdAcc -->|IAM: Deny<br/>iam:DeleteObject| S3[("Prod S3")]
ProdAcc -->|IAM: Deny<br/>ec2:TerminateInstances| EC2[("Prod EC2")]
DevAcc1 -->|IAM: Allow<br/>ec2:*| DevEC2[("Dev EC2")]
DevAcc1 -->|SCP Deny over-ride| DevEC2
style Root fill:#fff3cd
style OU_Prod fill:#f8d7da
style OU_Dev fill:#d1ecf1
style OU_Sec fill:#d4edda
この構成のポイントを挙げるとこんな感じだ。
Organization Rootには「絶対に守りたいこと」だけをSCPで書く。CloudTrail停止、SCP削除、root権限乱用の防止。ここが守られなければ、下位の全てが意味を失う。
OU別のSCPでは、環境特性に応じた制約を追加。Prodでは「本番データの削除を強く制限」する。Devでは「実験を促進するため最小限」に留める。環境によって必要な制約が違うんだよ。
Account-level IAMで個別のロール管理。SCPは「ガードレール」だから、IAMで「誰が、どのロールで、どこまで」を細かく定義する。この組み合わせが大事。
CloudTrail集約で、SCP由来とIAM由来の拒否を両方ログに記録。あとから分析できるようにしておく。
CloudTrailでSCP効果を実測する
SCPを運用するなら、CloudTrailでちゃんと効いてるか検証する仕組みが必須だ。うちが半年かけて構築したのがこれ。
# SCP効果を検証するLambda関数
import json
import boto3
from datetime import datetime, timedelta
athena = boto3.client('athena')
def check_scp_effectiveness():
# 過去7日間のCloudTrailから
# SCPで拒否された操作をカウント
query = """
SELECT
userIdentity.principalId,
eventName,
COUNT(*) as deny_count,
sourceIPAddress
FROM cloudtrail_logs
WHERE eventTime >= date_format(current_timestamp - interval '7' day, '%Y-%m-%dT%H:%i:%sZ')
AND errorCode = 'AccessDenied'
AND requestParameters LIKE '%TerminateInstances%'
GROUP BY userIdentity.principalId, eventName, sourceIPAddress
ORDER BY deny_count DESC
"""
response = athena.start_query_execution(
QueryString=query,
QueryExecutionContext={'Database': 'cloudtrail'},
ResultConfiguration={'OutputLocation': 's3://query-results/'}
)
return response
# 実行結果から、本当にSCPで止まってるのか
# IAMで止まってるのかを判定
def differentiate_deny_source(cloudtrail_event):
"""
CloudTrailログからSCP由来とIAM由来のDenyを判定
"""
event = json.loads(cloudtrail_event)
# SCP由来の場合、errorCode は AccessDenied
# だが、errorMessage には特定の文言が含まれない可能性がある
if event.get('errorCode') == 'AccessDenied':
error_msg = event.get('errorMessage', '')
# IAM的なエラー文言
if 'not authorized' in error_msg:
return 'IAM_DENY'
# SCP的なエラー(CloudTrail単独では区別が難しい)
# Organizations API経由でSCP確認するしかない
return 'POTENTIALLY_SCP'
return 'UNKNOWN'
実際に使ってみて気づいたのは、CloudTrailだけからはSCP由来とIAM由来の区別がほぼできないってことだ。結局、Organizations APIで「このアカウントに付いてるSCPは?」って確認するしかない。
organizations = boto3.client('organizations')
def get_effective_policy(account_id):
"""
あるアカウントに実際に効いてるSCPを全部取得
"""
policies = []
# 親OUから順に遡る
parent = organizations.list_parents(ChildId=account_id)
parent_id = parent['Parents'][0]['Id']
# OU階層の上まで遡る
while parent_id:
ou_policies = organizations.list_policies_for_target(
TargetId=parent_id,
Filter='SERVICE_CONTROL_POLICY'
)
for policy_id in ou_policies['Policies']:
policy_detail = organizations.describe_policy(
PolicyId=policy_id['Id']
)
policies.append(policy_detail['Policy'])
# 親をたどる
try:
parent = organizations.list_parents(ChildId=parent_id)
parent_id = parent['Parents'][0]['Id']
except:
break
return policies
これを定期的に実行して、「本当はこのSCPが効くはずだけど、実際には許可されてる操作」みたいな異常を検出する。地味だけど、この検証がないと「本当に機能してるのか」って不安が消えない。
ベストプラクティス:最初の判断基準
1年半運用してきた経験から、SCPを書く前に絶対確認する3つのポイントがある。
1. 「本当にSCPでいいのか?」を問い直す
SCPは強力だけど、その分難しい。同じ目的なら、以下の優先順位で検討する。
- IAMポリシー・権限境界 — 個別ユーザー・ロール単位で制御できるなら、まずこれを選ぶ。粒度が細かく、デバッグが簡単だ。
- リソースベースポリシー — S3バケットポリシーやKMS等、リソース側で制御できるなら活用する。
- SCP — 本当に「全Organization」「全アカウント」で統一したい制約のみ。これが最後の手段。
うちが失敗した例を挙げると、「Prod環境でのEC2削除を禁止したい」って要件に対してSCPで全アカウント制限してしまったんだ。実は本番環境のアカウントだけ制限すれば良かったのに。後からIAM権限境界に変更したよ。
2. SCPは段階的に導入
一度に複数のSCPを導入すると、何が原因で失敗しているのか、追跡不可能になる。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Phase1_CriticalServices",
"Comment": "本当に必須の制約。2026年Q3に導入",
"Effect": "Deny",
"Action": ["cloudtrail:StopLogging", "cloudtrail:DeleteTrail"],
"Resource": "*"
},
{
"Sid": "Phase2_RootProtection",
"Comment": "Phase1安定後。2026年Q4実装予定",
"Effect": "Deny",
"Action": ["iam:CreateAccessKey"],
"Resource": "arn:aws:iam::*:user/root"
}
]
}
こんな感じで、コメントに導入時期を明記しておく。バージョン管理もしておくと、「あのSCPいつ入ったっけ?」って状況が避けられる。
3. 例外は明示的に許可リスト化
SCPはDeny中心なので、「このアカウントだけは例外」みたいなケースが必ず出てくるんだ。その場合は、Organizations APIで明示的に上書きできる仕組みを作るのが吉。
# 例外管理用DynamoDB
class SCPExceptionManager:
def add_exception(self, account_id, action, reason, expiry_date):
"""
SCPの例外を明示的に記録
- 例外理由
- 有効期限
- 承認者
"""
exceptions_table.put_item(
Item={
'account_id': account_id,
'action': action,
'reason': reason,
'expires': expiry_date,
'created_at': datetime.now().isoformat(),
'approved_by': 'security-team@company.com'
}
)
def check_exception(self, account_id, action):
"""
操作時に例外があるかチェック
"""
response = exceptions_table.get_item(
Key={'account_id': account_id, 'action': action}
)
if 'Item' in response:
item = response['Item']
if item['expires'] > datetime.now():
return True # 例外が有効
return False
例外があると知っているのと知らないのでは、運用の安心感が全然違う。「なぜこの操作が許可されてるのか」が明確に分かる仕組みって大事なんだよ。
運用で気づいたこと:SCP + IAM + Organizations API の三角関係
9ヶ月目あたりで、「あ、複数のSCPが同時に効くんだ」って理解した。これが地味に重い。
Organization Root(全アカウント)にSCPがあって、かつ個別OUにもSCPがあると、両方が「AND」で効く。
Root SCP: Deny ec2:TerminateInstances
AND
OU SCP: Deny ec2:DeleteSnapshot
=
結果: ec2:TerminateInstances, ec2:DeleteSnapshot の両方が拒否される
これ自体は理にかなってるけど、本番で複数人が複数のSCPを管理してると、「あ、上位OUのSCPを見落としてた」みたいなバグが起きる。
だから、SCPは一箇所で集約管理するのがおすすめだ。うちは専用のCDKスタック作った。
// lib/scp-stack.ts
export class SCPManagementStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Organization Root SCP
const rootPolicy = new scp.Policy(this, 'RootPolicy', {
content: scp.PolicyDocument.fromFile('policies/root-baseline.json'),
name: 'root-baseline',
description: 'Critical controls for entire organization',
});
// OU-specific SCPs
const prodOUPolicy = new scp.Policy(this, 'ProdOUPolicy', {
content: scp.PolicyDocument.fromFile('policies/prod-ou.json'),
name: 'prod-ou-controls',
description: 'Production environment restrictions',
});
// Tag-based SCPs (2026年新機能)
const taggedPolicy = new scp.Policy(this, 'TaggedPolicy', {
content: new scp.PolicyDocument({
statements: [
new scp.PolicyStatement({
effect: scp.Effect.DENY,
actions: ['ec2:TerminateInstances'],
resources: ['*'],
conditions: {
StringEquals: {
'aws:ResourceTag/Environment': 'production'
}
}
})
]
}),
name: 'tag-based-controls',
});
}
}
IaCで管理することで、「どのOUにどのSCPが付いてるか」が一目瞭然。変更履歴もGitに残る。後から「なぜこのSCPが入ったのか」って経緯を追跡できるのは、地味に便利だ。
まとめ
正直に言うと、SCP運用で本当に大事なことってシンプルなんだ。
SCPとIAMの役割分担を明確にする — SCPは最小限のガードレール。細かい権限制御はIAM。この区別がつかないと、本当にカオスになる。
CloudTrail + Organizations APIで可視化する — 「これ、本当に効いてるの?」という不安は、ログと検証で払拭する。効果測定なしにSCPは語れない。
段階的導入と明示的な例外管理 — 一度に全部は入れない。必ず例外は出てくる。その例外を仕組みで管理する。
IaCで集約管理する — 複数人が管理するとカオス。CDKやTerraformで一元化する。誰がいつ何を変えたか、履歴が残る。
本当に必要なのか、毎回問い直す — SCPは強力だけど複雑。IAMポリシーやリソースベースポリシーで実現できないか、常に検討する癖をつける。
正直、SCP導入のハードルは思った以上に高い。でも、マルチアカウント環境では「ガードレール」の発想は本当に大事。ただし、教科書通りでは絶対動かない。実務で試行錯誤するしかない。
皆さんのチームでSCP導入の際は、この失敗と教訓が少しでも役に立てばと思う。