Skip to content
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
51 changes: 47 additions & 4 deletions cloudformation/collector-role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,13 @@ Resources:
Action:
- aidevops:CreateAgentSpace
- aidevops:AssociateService
# Resource '*' is required: aidevops:CreateAgentSpace and
# aidevops:AssociateService do not support resource-level
# permissions (similar to ecr:GetAuthorizationToken).
# Enable "web operator access" (the Operator Web App) on a
# newly created space, using the operator-app role below.
- aidevops:EnableOperatorApp
- aidevops:GetOperatorApp
# Resource '*' is required: these actions do not support
# resource-level permissions (similar to
# ecr:GetAuthorizationToken).
Resource: '*'
- !Ref AWS::NoValue
# Attaching the primary account passes the monitor role below to
Expand All @@ -99,7 +103,12 @@ Resources:
- Sid: PassMonitorRole
Effect: Allow
Action: iam:PassRole
Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/DevOpsAgentSpaceMonitorRole'
# The monitor role (primary-account association) and the
# operator-app role (web operator access) are both passed to
# the DevOps Agent service.
Resource:
- !Sub 'arn:aws:iam::${AWS::AccountId}:role/DevOpsAgentSpaceMonitorRole'
- !Sub 'arn:aws:iam::${AWS::AccountId}:role/DevOpsAgentOperatorAppRole'
Condition:
StringEquals:
iam:PassedToService: aidevops.amazonaws.com
Expand Down Expand Up @@ -130,6 +139,36 @@ Resources:
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AIDevOpsAgentAccessPolicy

# Role end users assume to reach a space's Operator Web App ("web operator
# access") in this account. It is the operatorAppRoleArn passed to
# EnableOperatorApp (auth flow iam) for spaces the hub creates here (mirrors
# the hub account's operator-app role provisioned by the Amplify backend).
# Created only when agent-space creation is enabled, since it is only needed
# then. The operator app tags the assumed session with the AgentSpaceId, so
# the trust allows sts:TagSession in addition to sts:AssumeRole.
OperatorRole:
Type: AWS::IAM::Role
Condition: CreateMonitorRole
Properties:
RoleName: DevOpsAgentOperatorAppRole
Description: Assumed by AWS DevOps Agent Operator Web App users to access spaces in this account (web operator access for spaces created by the hub).
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: aidevops.amazonaws.com
Action:
- sts:AssumeRole
- sts:TagSession
Condition:
StringEquals:
aws:SourceAccount: !Ref AWS::AccountId
ArnLike:
aws:SourceArn: !Sub 'arn:aws:aidevops:*:${AWS::AccountId}:agentspace/*'
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AIDevOpsOperatorAppAccessPolicy

Outputs:
CollectorRoleArn:
Description: ARN of the collector role (same name in every account).
Expand All @@ -138,3 +177,7 @@ Outputs:
Condition: CreateMonitorRole
Description: ARN of the monitor role the DevOps Agent service assumes (present only in non-hub accounts when agent-space creation is enabled).
Value: !GetAtt MonitorRole.Arn
OperatorRoleArn:
Condition: CreateMonitorRole
Description: ARN of the operator-app role for web operator access (present only in non-hub accounts when agent-space creation is enabled).
Value: !GetAtt OperatorRole.Arn
26 changes: 22 additions & 4 deletions scripts/03_collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,40 @@
"""
import io
import json
import os
import zipfile
import datetime as dt
from botocore.exceptions import ClientError
from _common import CFG, hub_session, list_org_accounts, assume_collector

REGION = CFG["REGION"]
BUCKET = CFG["HUB_BUCKET"]
S3 = hub_session().client("s3")
# Config lookups fall back to safe defaults so importing this module (e.g. from
# unit tests) never raises when HUB_BUCKET/REGION are unset. A real collection
# run resolves them from config.env / the environment; the lazy s3() below
# refuses to run against the placeholder bucket.
REGION = CFG.get("REGION") or os.getenv("AWS_DEFAULT_REGION", "us-east-1")
BUCKET = CFG.get("HUB_BUCKET") or os.getenv("HUB_BUCKET", "unit-test-placeholder-bucket")

_S3 = None


def s3():
"""Lazily create the hub S3 client so importing this module never requires
AWS credentials or a configured profile. Refuses to run when HUB_BUCKET is
not actually configured (i.e. still the unit-test placeholder)."""
global _S3
if _S3 is None:
if not BUCKET or BUCKET == "unit-test-placeholder-bucket":
raise RuntimeError("HUB_BUCKET must be configured for collection")
_S3 = hub_session().client("s3", region_name=REGION)
return _S3


def js(o):
return json.dumps(o, default=str, indent=2)


def put(key, obj):
S3.put_object(Bucket=BUCKET, Key=key, Body=js(obj).encode(), ContentType="application/json")
s3().put_object(Bucket=BUCKET, Key=key, Body=js(obj).encode(), ContentType="application/json")


def extract_zip_texts(zip_bytes):
Expand Down
6 changes: 5 additions & 1 deletion scripts/04_transform_to_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,13 @@
import csv
import io
import json
import os
from _common import CFG, hub_session

BUCKET = CFG["HUB_BUCKET"]
# Falls back to a placeholder so importing this module (e.g. from unit tests)
# never raises when HUB_BUCKET is unset; a real run resolves it from config.env
# / the environment. The S3 client below is created lazily.
BUCKET = CFG.get("HUB_BUCKET") or os.getenv("HUB_BUCKET", "unit-test-placeholder-bucket")

#: Max length of a truncated free-text label (Requirements 9.6, 9.7).
LABEL_MAX = 120
Expand Down
8 changes: 6 additions & 2 deletions scripts/07_build_kb_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,16 @@
Run with hub credentials (profile 123456789012).
"""
import json
import os
from collections import Counter

from _common import CFG, hub_session

BUCKET = CFG["HUB_BUCKET"]
DOCS = CFG["KB_DOCS_PREFIX"]
# Fall back to safe defaults so importing this module (e.g. from unit tests)
# never raises when HUB_BUCKET/KB_DOCS_PREFIX are unset; a real run resolves
# them from config.env / the environment. The S3 client below is created lazily.
BUCKET = CFG.get("HUB_BUCKET") or os.getenv("HUB_BUCKET", "unit-test-placeholder-bucket")
DOCS = CFG.get("KB_DOCS_PREFIX") or os.getenv("KB_DOCS_PREFIX", "kb/")

_S3 = None

Expand Down
8 changes: 6 additions & 2 deletions scripts/assemble_manifest_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@
"""
import datetime as dt
import json
import os

from _common import CFG, hub_session

BUCKET = CFG["HUB_BUCKET"]
REGION = CFG["REGION"]
# Fall back to safe defaults so importing this module (e.g. from unit tests)
# never raises when HUB_BUCKET/REGION are unset; a real run resolves them from
# config.env / the environment (the S3 client is created lazily in the handler).
BUCKET = CFG.get("HUB_BUCKET") or os.getenv("HUB_BUCKET", "unit-test-placeholder-bucket")
REGION = CFG.get("REGION") or os.getenv("AWS_DEFAULT_REGION", "us-east-1")
MANIFEST_KEY = "raw/_manifest.json"
ACCOUNT_PREFIX = "raw/account="
ACCOUNT_SUMMARY_SUFFIX = "/_account.json"
Expand Down
116 changes: 91 additions & 25 deletions scripts/create_space_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@
SERVICE_ID_AWS = "aws"
# Convention for the per-account monitor role the DevOps Agent service assumes.
DEFAULT_MONITOR_ROLE_NAME = "DevOpsAgentSpaceMonitorRole"
# Convention for the per-account operator-app role end users assume to reach a
# space's Operator Web App ("web operator access"). Mirrors the monitor role.
DEFAULT_OPERATOR_ROLE_NAME = "DevOpsAgentOperatorAppRole"
# Auth flow for EnableOperatorApp. "iam" needs only the operator-app role (the
# "default IAM role for access"); "idc"/"idp" would need extra configuration.
OPERATOR_AUTH_FLOW = "iam"

# Keep in sync with shared-types AGENT_SPACE_* bounds (validated again here so a
# direct invoke — not only the Node route — can't create out-of-bounds names).
Expand Down Expand Up @@ -89,6 +95,36 @@ def monitor_role_arn(account_id):
return f"arn:aws:iam::{account_id}:role/{role_name}"


def operator_role_arn(account_id):
"""
The IAM role end users assume to reach the space's Operator Web App ("web
operator access"), passed to EnableOperatorApp. For the hub account the role
is provisioned by the app (its ARN is injected as AGENT_OPERATOR_ROLE_ARN);
for other accounts a conventionally-named role in that account is used.
Returns None only when the hub role env is unset for the hub account.
"""
hub = CFG.get("HUB_ACCOUNT_ID")
if hub and account_id == hub:
arn = os.environ.get("AGENT_OPERATOR_ROLE_ARN")
return arn or None
role_name = os.environ.get("AGENT_OPERATOR_ROLE_NAME", DEFAULT_OPERATOR_ROLE_NAME)
return f"arn:aws:iam::{account_id}:role/{role_name}"


def enable_operator_app(client, agent_space_id, role_arn):
"""
Enable the space's Operator Web App ("web operator access") with the IAM
auth flow and the given default operator-app role. Returns the response dict
on success; raises ClientError on failure so the caller can record a
best-effort warning without failing the space creation.
"""
return client.enable_operator_app(
agentSpaceId=agent_space_id,
authFlow=OPERATOR_AUTH_FLOW,
operatorAppRoleArn=role_arn,
)


def associate_primary_account(client, agent_space_id, account_id, role_arn):
"""
Associate ``account_id`` as the space's PRIMARY (monitor) account. Returns
Expand All @@ -108,19 +144,22 @@ def associate_primary_account(client, agent_space_id, account_id, role_arn):
)["association"]


def create_space(account_id, name, description=None):
def create_space(account_id, name, description=None, enable_web_operator=True):
"""
Create one agent space in ``account_id`` and attach that account as the
space's primary (monitor) account (the minimum useful configuration). Pure
of the Lambda event shape so it is unit-testable. Returns a result dict:
Create one agent space in ``account_id``, attach that account as the space's
primary (monitor) account, and (when ``enable_web_operator``) enable its
Operator Web App ("web operator access") with a default IAM role. Pure of
the Lambda event shape so it is unit-testable. Returns a result dict:
success : {"ok": True, "accountId", "agentSpaceId", "name",
"primaryAccountConfigured": bool, "warning"?: str}
"primaryAccountConfigured": bool, "webOperatorEnabled"?: bool,
"warning"?: str}
failure : {"ok": False, "code": <str>, "error": <str>}
where ``code`` is one of: validation | create_denied | conflict | error.

Primary-account association is best-effort: if it fails (e.g. the monitor
role is missing in a linked account) the space is still created and a
``warning`` explains that the primary account was not configured.
Both the primary-account association and the operator-app enablement are
best-effort: if either fails (e.g. the required role is missing in a linked
account) the space is still created and a ``warning`` explains what was not
configured. Multiple warnings are joined into the single ``warning`` field.
"""
invalid = _validate(account_id, name, description)
if invalid:
Expand Down Expand Up @@ -162,27 +201,52 @@ def create_space(account_id, name, description=None):
"primaryAccountConfigured": False,
}

warnings = []

# Attach the hosting account as the primary (monitor) account. Best-effort:
# the space already exists, so a failure here is a warning, not an error.
role_arn = monitor_role_arn(account_id)
if not role_arn:
result["warning"] = (
"The space was created but no monitor role is configured for the hub "
"account (AGENT_MONITOR_ROLE_ARN is unset), so the primary account "
"was not attached."
)
return result
try:
associate_primary_account(client, agent_space_id, account_id, role_arn)
result["primaryAccountConfigured"] = True
except ClientError as e:
code = e.response["Error"]["Code"]
msg = e.response["Error"]["Message"][:200]
result["warning"] = (
f"The space was created but the primary account could not be attached "
f"({code}: {msg}). Ensure role {role_arn} exists in account {account_id} "
"and trusts aidevops.amazonaws.com."
warnings.append(
"no monitor role is configured for the hub account "
"(AGENT_MONITOR_ROLE_ARN is unset), so the primary account was not attached"
)
else:
try:
associate_primary_account(client, agent_space_id, account_id, role_arn)
result["primaryAccountConfigured"] = True
except ClientError as e:
code = e.response["Error"]["Code"]
msg = e.response["Error"]["Message"][:200]
warnings.append(
f"the primary account could not be attached ({code}: {msg}); ensure role "
f"{role_arn} exists in account {account_id} and trusts aidevops.amazonaws.com"
)

# Enable "web operator access" (the Operator Web App) with a default IAM
# role. Best-effort and independent of the primary-account step above.
if enable_web_operator:
result["webOperatorEnabled"] = False
op_role_arn = operator_role_arn(account_id)
if not op_role_arn:
warnings.append(
"no operator-app role is configured for the hub account "
"(AGENT_OPERATOR_ROLE_ARN is unset), so web operator access was not enabled"
)
else:
try:
enable_operator_app(client, agent_space_id, op_role_arn)
result["webOperatorEnabled"] = True
except ClientError as e:
code = e.response["Error"]["Code"]
msg = e.response["Error"]["Message"][:200]
warnings.append(
f"web operator access could not be enabled ({code}: {msg}); ensure role "
f"{op_role_arn} exists in account {account_id} and trusts aidevops.amazonaws.com"
)

if warnings:
result["warning"] = "The space was created but " + "; ".join(warnings) + "."
return result


Expand All @@ -191,4 +255,6 @@ def handler(event, _context=None):
account_id = _field(event, "accountId", "account", "id", "Account")
name = _field(event, "name", "spaceName", "Name")
description = _field(event, "description", "Description")
return create_space(account_id, name, description)
# Web operator access defaults ON; the Node route sends an explicit boolean.
enable_web_operator = event.get("webOperator", True) if isinstance(event, dict) else True
return create_space(account_id, name, description, enable_web_operator=bool(enable_web_operator))
6 changes: 5 additions & 1 deletion scripts/list_accounts_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@
``sts:AssumeRole`` on that one role.
"""
import json
import os

from _common import CFG, hub_session, list_org_accounts

BUCKET = CFG["HUB_BUCKET"]
# Falls back to a placeholder so importing this module (e.g. from unit tests)
# never raises when HUB_BUCKET is unset; a real run resolves it from config.env
# / the environment (the S3 client is created lazily inside the handler).
BUCKET = CFG.get("HUB_BUCKET") or os.getenv("HUB_BUCKET", "unit-test-placeholder-bucket")
# Fixed key (single-flight refresh, single admin actor -> no per-run collision).
ACCOUNTS_KEY = "refresh/accounts.json"

Expand Down
Loading
Loading