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
239 changes: 239 additions & 0 deletions deploy/cloudformation/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,18 @@ Parameters:
Default: 'main'
Description: "Git branch to clone on the EC2 instance. Use for testing feature branches."

EnableWebUIAuth:
Type: String
Default: 'false'
AllowedValues: ['true', 'false']
Description: "Enable Cognito-based WebUI authentication for KiroCrew dashboard."

WebUIAdminEmail:
Type: String
Default: ''
Description: "Email for the initial WebUI admin user. Required when EnableWebUIAuth is true."
AllowedPattern: '^([^@]+@[^@]+\.[^@]+)?$'
Comment on lines +369 to +373

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require an admin email whenever authentication is enabled

A direct CloudFormation or console deployment can set EnableWebUIAuth=true while leaving this parameter at its allowed empty default. CloudFormation then creates the preceding resources before WebUIUserCreationFunction rejects the missing email at template.yaml:1555-1557, causing a late full-stack rollback. Add a template rule tying a nonempty email to the enabled-auth setting so this invalid combination is rejected before deployment.

Useful? React with 👍 / 👎.


# ============================================================================
# RULES
# ============================================================================
Expand Down Expand Up @@ -401,6 +413,9 @@ Conditions:
RunSecurityServices: !Not [!Condition IsPersonalAssistant]
RunBedrockForm: !Equals [!Ref EnableBedrockForm, 'true']
IsKiroCrew: !Equals [!Ref PackName, 'kirocrew']
EnableWebUI: !And
- !Condition IsKiroCrew
- !Equals [!Ref EnableWebUIAuth, 'true']
# KiroCrewSubnet2 (in-template second AZ subnet) only needed on new-VPC path;
# existing-VPC path uses ExistingSubnetId2 passed by the caller.
KiroCrewNeedsSubnet2: !And
Expand Down Expand Up @@ -1389,6 +1404,205 @@ Resources:
DashboardUrl: !Sub 'https://${KiroCrewDistribution.DomainName}'
Region: !Ref 'AWS::Region'

# --------------------------------------------------------------------------
# WebUI Authentication (Cognito) — created only for KiroCrew with auth enabled
# --------------------------------------------------------------------------
WebUIUserPool:
Type: AWS::Cognito::UserPool
Condition: EnableWebUI
Properties:
UserPoolName: !Sub 'lowkey-${PackName}-${EnvironmentName}'
AdminCreateUserConfig:
AllowAdminCreateUserOnly: true
AutoVerifiedAttributes:
- email
UsernameAttributes:
- email
Policies:
PasswordPolicy:
MinimumLength: 12
RequireUppercase: true
RequireLowercase: true
RequireNumbers: true
RequireSymbols: true
TemporaryPasswordValidityDays: 1
Schema:
- Name: email
Required: true
Mutable: true
UserPoolTags:
loki:managed: 'true'
loki:pack: !Ref PackName
loki:env: !Ref EnvironmentName

WebUIUserPoolDomain:
Type: AWS::Cognito::UserPoolDomain
Condition: EnableWebUI
Properties:
UserPoolId: !Ref WebUIUserPool
Domain: !Sub 'lowkey-${PackName}-${EnvironmentName}'
Comment on lines +1442 to +1443

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the Cognito domain unique across StackSet accounts

For StackSet deployments, every target account using the same region, pack, and environment requests exactly the same Cognito managed-login prefix, but these prefixes must be unique across accounts within a region. Only the first stack can create the domain and the remaining stacks will roll back; the previous installer explicitly appended a random suffix and retried on collision. Include account/region-derived uniqueness or retain a randomized collision-safe suffix.

Useful? React with 👍 / 👎.


WebUIUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Condition: EnableWebUI
DependsOn: KiroCrewDistribution
Properties:
UserPoolId: !Ref WebUIUserPool
ClientName: !Sub '${PackName}-webui'
GenerateSecret: false
ExplicitAuthFlows:
- ALLOW_USER_SRP_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
SupportedIdentityProviders:
- COGNITO
AllowedOAuthFlows:
- code
AllowedOAuthFlowsUserPoolClient: true
Comment on lines +1458 to +1460

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce Cognito before reporting the dashboard protected

When WebUI auth is enabled, this only creates an OAuth client; it never attaches authentication to the request path or passes the pool/client/domain to KiroCrew. The existing KiroCrewHTTPListener forwards every request directly (template.yaml:616-625), and packs/kirocrew/resources/kirocrew-gateway.service:6-17 contains no Cognito configuration, despite the mandatory server-side enforcement described in docs/design/kirocrew-webui-auth.md:38-50. Consequently, selecting the installer’s “enterprise-grade” protection still leaves the public CloudFront dashboard unauthenticated; wire the client into an enforcing proxy/gateway or fail the auth option until that integration exists.

Useful? React with 👍 / 👎.

AllowedOAuthScopes:
- openid
- email
CallbackURLs:
- !Sub 'https://${KiroCrewDistribution.DomainName}/auth/callback'
- 'http://localhost:5476/auth/callback'
LogoutURLs:
- !Sub 'https://${KiroCrewDistribution.DomainName}/'
- 'http://localhost:5476/'
PreventUserExistenceErrors: ENABLED
AccessTokenValidity: 1
IdTokenValidity: 1
RefreshTokenValidity: 30
TokenValidityUnits:
AccessToken: hours
IdToken: hours
RefreshToken: days

WebUIUserCreationRole:
Type: AWS::IAM::Role
Condition: EnableWebUI
Properties:
RoleName: !Sub '${EnvironmentName}-webui-user-creation-role'
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Policies:
- PolicyName: CognitoUserCreation
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- cognito-idp:AdminCreateUser
- cognito-idp:AdminSetUserPassword
Resource: !GetAtt WebUIUserPool.Arn

WebUIUserCreationFunction:
Type: AWS::Lambda::Function
Condition: EnableWebUI
Properties:
FunctionName: !Sub '${EnvironmentName}-webui-user-creation'
Runtime: python3.12
Handler: index.handler
Timeout: 60
Role: !GetAtt WebUIUserCreationRole.Arn
Code:
ZipFile: |
import json, urllib.request, secrets, string, boto3

def send_response(event, context, status, reason='', data={}):
reason_str = (reason or f'See CW: {context.log_stream_name}')[:256]
phys_id = (context.log_stream_name or 'custom-resource')[-64:]
safe_data = {k: str(v)[:128] for k, v in (data or {}).items()}
body = json.dumps({
'Status': status, 'Reason': reason_str,
'PhysicalResourceId': phys_id,
'StackId': event['StackId'],
'RequestId': event['RequestId'],
'LogicalResourceId': event['LogicalResourceId'],
'Data': safe_data if len(json.dumps(safe_data)) < 1024 else {}
}).encode()
req = urllib.request.Request(event['ResponseURL'], data=body,
headers={'Content-Type': 'application/json', 'Content-Length': len(body)},
method='PUT')
urllib.request.urlopen(req)

def generate_password():
upper = secrets.choice(string.ascii_uppercase)
lower = secrets.choice(string.ascii_lowercase)
digit = secrets.choice(string.digits)
symbol = secrets.choice('!@#$%&*')
remainder = [secrets.choice(string.ascii_letters + string.digits + '!@#$%&*') for _ in range(12)]
pwd = [upper, lower, digit, symbol] + remainder
secrets.SystemRandom().shuffle(pwd)
return ''.join(pwd)

def handler(event, context):
print(f'[INFO] Event: {json.dumps(event)}')
if event['RequestType'] == 'Delete':
send_response(event, context, 'SUCCESS', 'Delete is a no-op')
return
Comment on lines +1546 to +1548

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the previous admin when its email changes

When an existing stack changes WebUIAdminEmail, the custom resource creates a new permanent-password user, receives a replacement physical ID because it is based on the invocation log stream, and then handles deletion of the old resource here as a no-op. The former email therefore retains valid dashboard access after an administrator believes it has been replaced; delete the old user during replacement or update the existing identity explicitly.

Useful? React with 👍 / 👎.


props = event.get('ResourceProperties', {})
pool_id = props.get('UserPoolId', '')
email = props.get('AdminEmail', '')
region = props.get('Region', 'us-east-1')

if not pool_id or not email:
send_response(event, context, 'FAILED', 'Missing UserPoolId or AdminEmail')
return

cognito = boto3.client('cognito-idp', region_name=region)
password = generate_password()

try:
cognito.admin_create_user(
UserPoolId=pool_id,
Username=email,
UserAttributes=[
{'Name': 'email', 'Value': email},
{'Name': 'email_verified', 'Value': 'true'}
],
MessageAction='SUPPRESS'
)
except cognito.exceptions.UsernameExistsException:
send_response(event, context, 'SUCCESS', 'User already exists',
{'Email': email, 'Password': '(existing user - password unchanged)'})
return
except Exception as e:
send_response(event, context, 'FAILED', f'User creation failed: {str(e)[:200]}')
return

try:
cognito.admin_set_user_password(
UserPoolId=pool_id,
Username=email,
Password=password,
Permanent=True
)
except Exception as e:
send_response(event, context, 'FAILED', f'Password set failed: {str(e)[:200]}')
return

send_response(event, context, 'SUCCESS', 'Initial user created',
{'Email': email, 'Password': password})

WebUIUserCreationResource:
Type: Custom::WebUIUserCreation
Condition: EnableWebUI
DependsOn:
- WebUIUserPool
- WebUIUserPoolClient
Properties:
ServiceToken: !GetAtt WebUIUserCreationFunction.Arn
UserPoolId: !Ref WebUIUserPool
AdminEmail: !Ref WebUIAdminEmail
Region: !Ref 'AWS::Region'

# SSM Session Manager Preferences (auto-login as ec2-user with welcome)
# --------------------------------------------------------------------------
# --------------------------------------------------------------------------
Expand Down Expand Up @@ -1582,3 +1796,28 @@ Outputs:
Description: KiroCrew ALB DNS name (do not access directly - use CloudFront)
Value: !GetAtt KiroCrewALB.DNSName

WebUICognitoPoolId:
Condition: EnableWebUI
Description: Cognito User Pool ID for WebUI auth
Value: !Ref WebUIUserPool

WebUICognitoClientId:
Condition: EnableWebUI
Description: Cognito App Client ID for WebUI auth
Value: !Ref WebUIUserPoolClient

WebUICognitoDomain:
Condition: EnableWebUI
Description: Cognito hosted-UI domain
Value: !Sub '${WebUIUserPoolDomain}.auth.${AWS::Region}.amazoncognito.com'

WebUIAdminEmailOutput:
Condition: EnableWebUI
Description: Email of the initial WebUI admin user
Value: !Ref WebUIAdminEmail

WebUIAdminPassword:
Condition: EnableWebUI
Description: One-time initial password for the WebUI admin user — save immediately, not retrievable later
Value: !GetAtt WebUIUserCreationResource.Password
Comment on lines +1819 to +1822

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the permanent admin password out of stack outputs

With auth enabled, the Lambda sets this generated password as Permanent=True and this output stores it in CloudFormation indefinitely. It is therefore retrievable repeatedly by principals with routine DescribeStacks access rather than being “shown only once,” giving those principals credentials for the WebUI admin account. Deliver a temporary password through Cognito or store the secret in a separately permissioned secret instead of returning it as a stack output.

Useful? React with 👍 / 👎.


Loading