;
+ onAdd: (value: string) => void;
+ onRemove: (value: string) => void;
+}
+
+export const ChipEditor = (props: Props) => {
+ const [isAdding, setIsAdding] = useState(false);
+
+ const visibleOptions = props.options.filter(
+ (option) => props.before.has(option.value) || props.after.has(option.value),
+ );
+
+ // Add options that are in before or after but not in the current options.
+ {
+ const optionValues = new Set(props.options.map((option) => option.value));
+ for (const value of props.before) {
+ if (!optionValues.has(value)) {
+ visibleOptions.push({ label: value, value });
+ }
+ }
+ for (const value of props.after) {
+ if (!optionValues.has(value)) {
+ visibleOptions.push({ label: value, value });
+ }
+ }
+ }
+
+ visibleOptions.sort((a, b) => a.label.localeCompare(b.label));
+
+ const neutralChip = 'chip px-1';
+ const addedChip = 'chip px-1 bg-mint';
+ const removedChip = 'chip px-1 line-through bg-indian-red';
+
+ return (
+
+ {visibleOptions.map((option) => {
+ const isAdded = props.after.has(option.value) && !props.before.has(option.value);
+ const isRemoved = props.before.has(option.value) && !props.after.has(option.value);
+
+ return (
+
+ {option.label}
+ {props.after.has(option.value) ? (
+ props.onRemove(option.value)}
+ />
+ ) : (
+ props.onAdd(option.value)}
+ />
+ )}
+
+ );
+ })}
+ {isAdding ? (
+ !props.before.has(option.value) && !props.after.has(option.value),
+ )}
+ onChange={(value) => {
+ props.onAdd(value);
+ setIsAdding(false);
+ }}
+ onClose={() => setIsAdding(false)}
+ />
+ ) : (
+ setIsAdding(true)}
+ />
+ )}
+
+ );
+};
+
+interface InlineComboboxProps {
+ options: Option[];
+ value?: string;
+ onChange?: (value: string) => void;
+ onClose?: () => void;
+}
+
+const InlineCombobox = (props: InlineComboboxProps) => {
+ const [query, setQuery] = useState('');
+
+ const filteredOptions = props.options
+ .filter((option) => option.label.toLowerCase().includes(query.toLowerCase()))
+ .sort((a, b) => a.label.localeCompare(b.label));
+
+ return (
+ {
+ setQuery('');
+ props.onClose?.();
+ }}
+ immediate
+ >
+ props.options.find((option) => option.value === value)?.label || ''}
+ onChange={(event) => setQuery(event.target.value)}
+ onBlur={() => {
+ setQuery('');
+ props.onClose?.();
+ }}
+ className="bg-english-violet px-2 py-0.5 mx-0.5 leading-none rounded-md text-xs font-semibold text-snow focus:outline-none"
+ />
+
+ {filteredOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+};
diff --git a/frontend/src/components/SuccessMessage.tsx b/frontend/src/components/SuccessMessage.tsx
index 1f9455c5..66728f75 100644
--- a/frontend/src/components/SuccessMessage.tsx
+++ b/frontend/src/components/SuccessMessage.tsx
@@ -1,7 +1,7 @@
import { CheckCircleIcon } from '@heroicons/react/24/outline';
export const SuccessMessage = ({ children }: { children: React.ReactNode }) => (
-
+
{children}
diff --git a/frontend/src/components/index.tsx b/frontend/src/components/index.tsx
index 80c692d6..6546ba65 100644
--- a/frontend/src/components/index.tsx
+++ b/frontend/src/components/index.tsx
@@ -1,6 +1,7 @@
export { BackgroundAnimation } from './BackgroundAnimation';
export { Button } from './Button';
export { Checkbox } from './Checkbox';
+export { ChipEditor } from './ChipEditor';
export { Dialog } from './Dialog';
export { DurationDropdown } from './DurationDropdown';
export { ErrorMessage } from './ErrorMessage';
diff --git a/frontend/src/hooks.ts b/frontend/src/hooks.ts
index 7e5703f3..3757a806 100644
--- a/frontend/src/hooks.ts
+++ b/frontend/src/hooks.ts
@@ -5,6 +5,7 @@ import {
AWSAccount,
AWSIntegration,
AWSRegion,
+ AWSSCP,
Report,
TeamBillingProfile,
TeamPaymentMethod,
@@ -476,3 +477,13 @@ export const useCurrentTeamPrincipalSettings = (principalKey: string): TeamPrinc
return undefined;
});
};
+
+export const useManagedAwsScp = (teamId: string, accountId: string): AWSSCP | undefined | null => {
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ dispatch.aws.fetchManagedScpByTeamAndAccountId({ teamId, accountId });
+ }, [teamId, accountId, dispatch]);
+
+ return useSelector((state) => state.aws.managedScps[accountId]);
+};
diff --git a/frontend/src/models/aws.ts b/frontend/src/models/aws.ts
index 6216f392..a4310efc 100644
--- a/frontend/src/models/aws.ts
+++ b/frontend/src/models/aws.ts
@@ -1,14 +1,16 @@
import { createModel } from '@rematch/core';
import { RootModel } from '.';
-import { apiConfiguration } from './api';
+import { apiConfiguration, ApiError } from './api';
import {
AwsApi,
AWSAccount,
AWSIntegration,
AWSRegion,
+ AWSSCP,
CreateAWSIntegrationInput,
+ PutAWSSCPInput,
UpdateAWSIntegrationInput,
} from '@/generated/api';
@@ -18,6 +20,7 @@ interface AwsState {
regions: Record;
accounts: Record;
teamAccountIds: Record;
+ managedScps: Record;
}
export const aws = createModel()({
@@ -27,6 +30,7 @@ export const aws = createModel()({
regions: {},
accounts: {},
teamAccountIds: {},
+ managedScps: {},
} as AwsState,
reducers: {
putIntegration(state, integration: AWSIntegration) {
@@ -44,6 +48,9 @@ export const aws = createModel()({
putAccount(state, account: AWSAccount) {
state.accounts[account.id] = account;
},
+ putManagedScp(state, accountId: string, scp: AWSSCP | null) {
+ state.managedScps[accountId] = scp;
+ },
setTeamAccountIds(state, teamId: string, accountIds: string[]) {
state.teamAccountIds[teamId] = accountIds;
},
@@ -94,6 +101,31 @@ export const aws = createModel()({
resp.map((a) => a.id),
);
},
+ async fetchManagedScpByTeamAndAccountId(payload: { teamId: string; accountId: string }, state) {
+ const api = new AwsApi(apiConfiguration(state.api));
+ try {
+ const resp = await api.getManagedAWSSCP(payload);
+ dispatch.aws.putManagedScp(payload.accountId, resp);
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 404) {
+ dispatch.aws.putManagedScp(payload.accountId, null);
+ } else {
+ throw err;
+ }
+ }
+ },
+ async putManagedScpByTeamAndAccountId(
+ payload: { teamId: string; accountId: string; input: PutAWSSCPInput },
+ state,
+ ) {
+ const api = new AwsApi(apiConfiguration(state.api));
+ const resp = await api.putManagedAWSSCP({
+ teamId: payload.teamId,
+ accountId: payload.accountId,
+ putAWSSCPInput: payload.input,
+ });
+ dispatch.aws.putManagedScp(payload.accountId, resp);
+ },
async createIntegration(payload: { teamId: string; input: CreateAWSIntegrationInput }, state) {
const api = new AwsApi(apiConfiguration(state.api));
const resp = await api.createAWSIntegration({
diff --git a/frontend/src/rules.ts b/frontend/src/rules.ts
new file mode 100644
index 00000000..b480d664
--- /dev/null
+++ b/frontend/src/rules.ts
@@ -0,0 +1,174 @@
+import { AwsPolicy } from '@/aws';
+
+export interface ServiceAllowlistRule {
+ type: 'service_allowlist';
+ services: Set;
+}
+
+export interface RegionAllowlistRule {
+ type: 'region_allowlist';
+ regions: Set;
+}
+
+export type Rule = ServiceAllowlistRule | RegionAllowlistRule;
+
+const toStrings = (thing: string | string[] | undefined): string[] => {
+ if (!thing) {
+ return [];
+ }
+ if (typeof thing === 'string') {
+ return [thing];
+ }
+ return thing;
+};
+
+export class RuleSet {
+ regionAllowlist?: RegionAllowlistRule;
+ serviceAllowlist?: ServiceAllowlistRule;
+
+ static fromScpContent(content: string): RuleSet {
+ const policy = JSON.parse(content) as AwsPolicy;
+ const ret = new RuleSet();
+ for (const statement of policy.Statement) {
+ switch (statement.Sid) {
+ case 'ServiceAllowlist':
+ ret.serviceAllowlist = {
+ type: 'service_allowlist',
+ services: new Set(toStrings(statement.NotAction).map((action) => action.split(':')[0])),
+ };
+ break;
+ case 'RegionAllowlist':
+ ret.regionAllowlist = {
+ type: 'region_allowlist',
+ regions: new Set(statement.Condition?.StringNotEquals['aws:RequestedRegion'] || []),
+ };
+ break;
+ }
+ }
+ return ret;
+ }
+
+ // Creates a deep clone of the RuleSet.
+ clone(): RuleSet {
+ const ret = new RuleSet();
+ if (this.serviceAllowlist) {
+ ret.serviceAllowlist = {
+ type: 'service_allowlist',
+ services: new Set(this.serviceAllowlist.services),
+ };
+ }
+ if (this.regionAllowlist) {
+ ret.regionAllowlist = {
+ type: 'region_allowlist',
+ regions: new Set(this.regionAllowlist.regions),
+ };
+ }
+ return ret;
+ }
+
+ equal(other: RuleSet): boolean {
+ if (this.serviceAllowlist && other.serviceAllowlist) {
+ if (this.serviceAllowlist.services.size !== other.serviceAllowlist.services.size) {
+ return false;
+ }
+ for (const service of this.serviceAllowlist.services) {
+ if (!other.serviceAllowlist.services.has(service)) {
+ return false;
+ }
+ }
+ } else if (this.serviceAllowlist || other.serviceAllowlist) {
+ return false;
+ }
+
+ if (this.regionAllowlist && other.regionAllowlist) {
+ if (this.regionAllowlist.regions.size !== other.regionAllowlist.regions.size) {
+ return false;
+ }
+ for (const region of this.regionAllowlist.regions) {
+ if (!other.regionAllowlist.regions.has(region)) {
+ return false;
+ }
+ }
+ } else if (this.regionAllowlist || other.regionAllowlist) {
+ return false;
+ }
+
+ return true;
+ }
+
+ addRegionToAllowlist(region: string) {
+ if (!this.regionAllowlist) {
+ this.regionAllowlist = { type: 'region_allowlist', regions: new Set() };
+ }
+ this.regionAllowlist.regions.add(region);
+ }
+
+ removeRegionFromAllowlist(region: string) {
+ if (this.regionAllowlist) {
+ this.regionAllowlist.regions.delete(region);
+ if (this.regionAllowlist.regions.size === 0) {
+ this.regionAllowlist = undefined;
+ }
+ }
+ }
+
+ hasRegionInAllowlist(region: string): boolean {
+ return this.regionAllowlist ? this.regionAllowlist.regions.has(region) : false;
+ }
+
+ addServiceToAllowlist(service: string) {
+ if (!this.serviceAllowlist) {
+ this.serviceAllowlist = { type: 'service_allowlist', services: new Set() };
+ }
+ this.serviceAllowlist.services.add(service);
+ }
+
+ removeServiceFromAllowlist(service: string) {
+ if (this.serviceAllowlist) {
+ this.serviceAllowlist.services.delete(service);
+ if (this.serviceAllowlist.services.size === 0) {
+ this.serviceAllowlist = undefined;
+ }
+ }
+ }
+
+ hasServiceInAllowlist(service: string): boolean {
+ return this.serviceAllowlist ? this.serviceAllowlist.services.has(service) : false;
+ }
+
+ scp(): AwsPolicy {
+ const policy: AwsPolicy = {
+ Version: '2012-10-17',
+ Statement: [],
+ };
+
+ if (this.serviceAllowlist) {
+ policy.Statement.push({
+ Sid: 'ServiceAllowlist',
+ Effect: 'Deny',
+ NotAction: Array.from(this.serviceAllowlist.services).map((service) => `${service}:*`),
+ Resource: '*',
+ });
+ }
+
+ if (this.regionAllowlist) {
+ policy.Statement.push({
+ Sid: 'RegionAllowlist',
+ Effect: 'Deny',
+ // Take special care with this core global services. They either work in all regions or not at all.
+ Action: this.hasRegionInAllowlist('us-east-1') ? '*' : undefined,
+ NotAction: this.hasRegionInAllowlist('us-east-1')
+ ? undefined
+ : ['iam:*', 'organizations:*', 'account:*'],
+ Resource: '*',
+ Condition: {
+ StringNotEquals: {
+ 'aws:RequestedRegion': Array.from(this.regionAllowlist.regions),
+ },
+ },
+ });
+ }
+
+ return policy;
+ }
+}
From af262898fce1136664b05fd4d138e88aa07bf352 Mon Sep 17 00:00:00 2001
From: Chris Brown <1731074+ccbrown@users.noreply.github.com>
Date: Sun, 20 Apr 2025 02:41:20 -0400
Subject: [PATCH 2/2] fix comment typo
---
frontend/src/rules.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/rules.ts b/frontend/src/rules.ts
index b480d664..c2cfeb08 100644
--- a/frontend/src/rules.ts
+++ b/frontend/src/rules.ts
@@ -155,7 +155,7 @@ export class RuleSet {
policy.Statement.push({
Sid: 'RegionAllowlist',
Effect: 'Deny',
- // Take special care with this core global services. They either work in all regions or not at all.
+ // Take special care with these core global services. They either work in all regions or not at all.
Action: this.hasRegionInAllowlist('us-east-1') ? '*' : undefined,
NotAction: this.hasRegionInAllowlist('us-east-1')
? undefined