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
40 changes: 40 additions & 0 deletions frontend/scripts/gather_aws_service_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import boto3
import json
import time

orgs = boto3.client('organizations')
iam = boto3.client('iam')

roots = orgs.list_roots()
root = roots['Roots'][0]
entity_path = root['Arn'].split('/', 1)[1]

job = iam.generate_organizations_access_report(EntityPath=entity_path)
job_id = job['JobId']

params = {
'JobId': job_id,
}

services = []

while True:
report = iam.get_organizations_access_report(**params)
if report['JobStatus'] == 'IN_PROGRESS':
time.sleep(1)
elif report['JobStatus'] == 'COMPLETED':
for service in report['AccessDetails']:
services.append({
'name': service['ServiceName'],
'namespace': service['ServiceNamespace'],
})

if report['IsTruncated']:
params['Marker'] = report['Marker']
else:
break
else:
raise Exception(f"Unexpected job status: {report['JobStatus']}")

print(json.dumps(services, indent=2))
7 changes: 6 additions & 1 deletion frontend/src/app/(public-area)/articles/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ export default async function Page({ params }: Props) {
<ul className="list-disc mt-4 pl-6">
{article.relatedLinks.map((item, index) => (
<li key={index}>
<Link href={item.url} target="_blank" rel="noopener noreferrer" className="external-link">
<Link
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="external-link"
>
{item.title}
</Link>
</li>
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/app/(user-area)/teams/[teamId]/MapOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -371,9 +371,7 @@ export const MapOverlays = (props: Props) => {
e.originalEvent.preventDefault();
e.originalEvent.stopPropagation();
}}
className={
'hover:z-200 transition-all duration-200 ease-in-out baz cursor-pointer' + extraClasses
}
className={'hover:z-200 transition-all duration-200 ease-in-out cursor-pointer' + extraClasses}
>
<MarkerContent
data={marker}
Expand Down
271 changes: 271 additions & 0 deletions frontend/src/app/(user-area)/teams/[teamId]/Rules.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import { clsx } from 'clsx';
import { useEffect, useMemo, useState } from 'react';
import { ChevronRightIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import { Transition } from '@headlessui/react';

import { awsServices } from '@/aws';
import { Button, ChipEditor, Dialog, ErrorMessage } from '@/components';
import { AWSAccount } from '@/generated/api';
import { useAwsRegions, useCurrentTeamId, useManagedAwsScp, useTeamAwsAccountsMap } from '@/hooks';
import { RuleSet } from '@/rules';
import { useDispatch } from '@/store';

interface PolicyPreviewProps {
account: AWSAccount;
ruleSet: RuleSet;
onSuccess: () => void;
}

const PolicyPreview = ({ account, onSuccess, ruleSet }: PolicyPreviewProps) => {
const teamId = useCurrentTeamId();
const [isBusy, setIsBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const dispatch = useDispatch();

const prettyContent = useMemo(() => JSON.stringify(ruleSet.scp(), null, 2), [ruleSet]);

const apply = async () => {
if (isBusy) {
return;
}
setIsBusy(true);

try {
await dispatch.aws.putManagedScpByTeamAndAccountId({
teamId,
accountId: account.id,
input: {
content: JSON.stringify(ruleSet.scp()),
},
});
onSuccess();
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : 'An unknown error occurred.');
} finally {
setIsBusy(false);
}
};

return (
<div className="flex flex-col gap-4">
{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
<p>
⚠️ If you proceed, the following service control policy will be applied to{' '}
<span className="font-semibold">{account.name ? `${account.name} (${account.id})` : account.id}</span>.
Please exercise caution as it is possible to lock yourself out or disrupt services running in the
account.
</p>
<pre className="p-2 border-1 border-english-violet/60 rounded-lg text-sm max-h-[50vh] overflow-auto">
<code>{prettyContent}</code>
</pre>
<Button disabled={isBusy} label="Apply Policy" onClick={() => apply()} />
</div>
);
};

interface AccountPageProps {
account: AWSAccount;
onBack: () => void;
}

const AccountPage = ({ account, onBack }: AccountPageProps) => {
const [isPreviewing, setIsPreviewing] = useState(false);
const awsRegions = useAwsRegions();
const teamId = useCurrentTeamId();
const scp = useManagedAwsScp(teamId, account.id);
const scpContent = scp && scp.content;
const scpRuleSet = useMemo(
() => (scpContent === undefined ? undefined : scpContent ? RuleSet.fromScpContent(scpContent) : new RuleSet()),
[scpContent],
);

const [ruleSet, setRuleSet] = useState(new RuleSet());
const updateRuleSet = (update: (prev: RuleSet) => void) => {
const newRuleSet = ruleSet.clone();
update(newRuleSet);
setRuleSet(newRuleSet);
};

const hasChanges = useMemo(() => scpRuleSet && !ruleSet.equal(scpRuleSet), [ruleSet, scpRuleSet]);

useEffect(() => {
if (scpRuleSet) {
setRuleSet(scpRuleSet.clone());
}
}, [scpRuleSet]);

return (
<div className="flex flex-col gap-4">
{scp === undefined ? (
<p>Loading...</p>
) : (
<>
<p className="font-semibold">{account.name ? `${account.name} (${account.id})` : account.id}</p>

<div className="border border-english-violet/60 bg-white/20 text-sm rounded-lg p-2 flex flex-col gap-2">
<div>
<span className="label">Region allowlist: </span>
<ChipEditor
options={awsRegions.map((region) => ({ label: region.id, value: region.id }))}
before={scpRuleSet?.regionAllowlist?.regions || new Set()}
after={ruleSet.regionAllowlist?.regions || new Set()}
onAdd={(region) => {
updateRuleSet((ruleSet) => ruleSet.addRegionToAllowlist(region));
}}
onRemove={(region) => {
updateRuleSet((ruleSet) => ruleSet.removeRegionFromAllowlist(region));
}}
/>
</div>
<div className="text-xs">
{ruleSet.regionAllowlist ? (
ruleSet.hasRegionInAllowlist('us-east-1') ? (
'Regions not listed above will be blocked.'
) : (
<span>
Regions not listed above will be blocked, with exceptions for IAM,
Organizations, and Account Management as{' '}
<Link
href="https://docs.aws.amazon.com/whitepapers/latest/aws-fault-isolation-boundaries/global-services.html"
className="external-link"
rel="noopener noreferrer"
target="_blank"
>
these are global services depending on us-east-1
</Link>
.
</span>
)
) : (
'All regions will be allowed.'
)}
</div>
</div>

<div className="border border-english-violet/60 bg-white/20 text-sm rounded-lg p-2 flex flex-col gap-2">
<div>
<span className="label">Service allowlist: </span>
<ChipEditor
options={awsServices.map((service) => ({
label: service.namespace,
value: service.namespace,
}))}
before={scpRuleSet?.serviceAllowlist?.services || new Set()}
after={ruleSet.serviceAllowlist?.services || new Set()}
onAdd={(service) => {
updateRuleSet((ruleSet) => ruleSet.addServiceToAllowlist(service));
}}
onRemove={(service) => {
updateRuleSet((ruleSet) => ruleSet.removeServiceFromAllowlist(service));
}}
/>
</div>
<div className="text-xs">
{ruleSet.serviceAllowlist
? 'Services not listed above will be blocked.'
: 'All services will be allowed.'}
</div>
</div>

<Dialog isOpen={isPreviewing} onClose={() => setIsPreviewing(false)} title="Policy Preview">
<PolicyPreview account={account} ruleSet={ruleSet} onSuccess={() => setIsPreviewing(false)} />
</Dialog>
<Button disabled={!hasChanges} label="Preview Policy" onClick={() => setIsPreviewing(true)} />
</>
)}
<div className="flex justify-center">
<span onClick={onBack} className="cursor-pointer text-sm link">
Back to Accounts
</span>
</div>
</div>
);
};

export const Rules = () => {
const teamId = useCurrentTeamId();
const teamAwsAccountsMap = useTeamAwsAccountsMap(teamId);
const [accountId, setAccountId] = useState<string | null>(null);
const [transitionDirection, setTransitionDirection] = useState<'forward' | 'backward'>('forward');

const account = accountId && teamAwsAccountsMap?.get(accountId);

const sortedAccounts =
teamAwsAccountsMap &&
Array.from(teamAwsAccountsMap.values()).sort((a, b) => {
const aLabel = a.name?.toLowerCase() || a.id;
const bLabel = b.name?.toLowerCase() || b.id;
return aLabel.localeCompare(bLabel);
});

const pageClassName = clsx([
'flex flex-col gap-2',
'data-[closed]:opacity-0 data-[closed]:absolute',
'data-[enter]:duration-100 data-[leave]:duration-300',
transitionDirection === 'forward' &&
'data-[enter]:data-[closed]:translate-x-full data-[leave]:data-[closed]:-translate-x-full',
transitionDirection === 'backward' &&
'data-[enter]:data-[closed]:-translate-x-full data-[leave]:data-[closed]:translate-x-full',
]);

return (
<div className="relative overflow-hidden">
<Transition show={!account}>
<div className={pageClassName}>
<p>
You can use Cloud Snitch to enforce rules for the following AWS accounts. Cloud Snitch does this
by attaching{' '}
<Link
href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html"
className="external-link"
rel="noopener noreferrer"
target="_blank"
>
Service Control Policies
</Link>{' '}
to accounts.
</p>
<div className="flex flex-col max-h-[50vh] overflow-auto">
{sortedAccounts &&
sortedAccounts.map((account) => (
<div
key={account.id}
className="flex items-center gap-2 hover:bg-white/80 rounded-md cursor-pointer p-2"
onClick={() => {
setTransitionDirection('forward');
setAccountId(account.id);
}}
>
{account.name ? (
<div className="flex flex-col">
<span className="font-semibold">{account.name}</span>
<span className="text-xs">{account.id}</span>
</div>
) : (
<span className="font-semibold">{account.id}</span>
)}
<div className="grow flex justify-end">
<ChevronRightIcon className="h-4 w-4 text-gray-400" />
</div>
</div>
))}
</div>
</div>
</Transition>
<Transition show={!!account}>
<div className={pageClassName}>
{account && (
<AccountPage
account={account}
onBack={() => {
setTransitionDirection('backward');
setAccountId(null);
}}
/>
)}
</div>
</Transition>
</div>
);
};
21 changes: 20 additions & 1 deletion frontend/src/app/(user-area)/teams/[teamId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
'use client';

import { ShieldCheckIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Map as WorldMap, MapRef } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css';

import { DurationDropdown, FilterDropdown } from '@/components';
import { Dialog, DurationDropdown, FilterDropdown } from '@/components';
import {
useAwsRegionsMap,
useCombinedReport,
Expand All @@ -23,6 +24,7 @@ import { Header } from './Header';
import { ContextPanel } from './ContextPanel';
import { maxZoomForClusterRect, minZoomForClusterRect, MapOverlays } from './MapOverlays';
import { NavigationPanel } from './NavigationPanel';
import { Rules } from './Rules';
import { isEqualSelection, useSelection, Selection } from './selection';

interface IdAndName {
Expand All @@ -42,6 +44,8 @@ const Page = () => {
return memberships && memberships[teamId]?.role === TeamMembershipRole.Administrator;
});

const [isRulesOpen, setIsRulesOpen] = useState(false);

const [map, setMap] = useState<MapRef | null>(null);
const [isMapReady, setIsMapReady] = useState(false);

Expand Down Expand Up @@ -256,6 +260,9 @@ const Page = () => {
teamAwsIntegrationsIfAdminAndNoReports && teamAwsIntegrationsIfAdminAndNoReports.length === 0;
const needsSetup = needsSubscriptionSetup || needsAwsIntegrationSetup;

const canManageScps =
teamAwsAccountsMap && [...teamAwsAccountsMap.values()].some((account) => account.canManageScps);

return (
<div className="w-full h-screen relative">
<div className="absolute top-0 left-0 w-full h-full isolate">
Expand Down Expand Up @@ -311,6 +318,18 @@ const Page = () => {
availableEndTime={maxUnfilteredReportEndTime}
onChange={setDurationSeconds}
/>
<Dialog isOpen={isRulesOpen} onClose={() => setIsRulesOpen(false)} title="Rules">
<Rules />
</Dialog>
{canManageScps && (
<div
className="inline-flex items-center gap-2 rounded-md bg-mint hover:bg-emerald transition-all duration-200 ease-in-out ml-8 py-1.5 px-3 text-sm/6 font-semibold text-snow cursor-pointer"
onClick={() => setIsRulesOpen(true)}
>
<ShieldCheckIcon className="h-[1.2rem]" />
Rules
</div>
)}
</div>
</Header>
<main className="w-full grow p-4 flex flex-row min-h-0 pointer-events-none relative">
Expand Down
Loading
Loading