diff --git a/.env.example b/.env.example index cc0ad78d2f..2e2b09b21c 100644 --- a/.env.example +++ b/.env.example @@ -252,6 +252,10 @@ # token TTL (5 minutes) has elapsed. # PROBOD_IDENTITY_FEDERATION_PREVIOUS_SIGNING_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" # PROBOD_IDENTITY_FEDERATION_PREVIOUS_SIGNING_KEY_KID=previous +# Install artifacts for the AWS audit role. Defaults point at the public +# CloudFormation template and the Terraform Registry module. +# PROBOD_IDENTITY_FEDERATION_CLOUDFORMATION_TEMPLATE_URL=https://probo-cloudformation-template.s3.us-east-2.amazonaws.com/aws-audit-role.yaml +# PROBOD_IDENTITY_FEDERATION_TERRAFORM_MODULE_SOURCE=getprobo/audit-role/aws # ── Custom domains (step-ca ACME via compose, direct TLS mode only) ─── # PROBOD_CUSTOM_DOMAINS_CNAME_TARGET=custom.getprobo.com diff --git a/.github/workflows/release-terraform-aws-audit-role.yaml b/.github/workflows/release-terraform-aws-audit-role.yaml index e25f194ca4..2baabace96 100644 --- a/.github/workflows/release-terraform-aws-audit-role.yaml +++ b/.github/workflows/release-terraform-aws-audit-role.yaml @@ -53,7 +53,8 @@ jobs: \`\`\`hcl module "probo_audit" { - source = "github.com/getprobo/terraform-aws-audit-role?ref=v${VERSION}" + source = "getprobo/audit-role/aws" + version = "${VERSION}" } \`\`\` EOF diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json index 92c375bff1..fc5511a585 100644 --- a/apps/console/src/_locales/en-US.json +++ b/apps/console/src/_locales/en-US.json @@ -1851,7 +1851,6 @@ }, "addAccessReviewSourceDialog": { "actions": { "connectWithOAuth": "OAuth", "connectWithGitHubApp": "GitHub App", "connectWithApiKey": "API Key", "connectWithClientCredentials": "Client Credentials", "connectWithWorkloadIdentity": "Workload Identity", "chooseAnotherMethod": "Choose another connection method", "open": "Open" }, - "comingSoon": "Coming soon!", "csv": { "title": "CSV", "description": "Upload CSV data directly as an access source." } }, "addCampaignSourceDialog": { @@ -1897,6 +1896,46 @@ "region": { "placeholder": "Select a region", "us": "United States (US)", "ca": "Canada (CA)", "eu": "Europe (EU)" }, "actions": { "connect": "Connect", "connecting": "Connecting..." } }, + "createAwsAccessReviewSourcePage": { + "pageTitle": "Connect AWS", + "title": "Connect AWS", + "description": "Deploy the audit role. Then enter its ARN.", + "permissionDenied": "You do not have permission to create access sources.", + "fields": { + "roleArn": "Role ARN", + "issuer": "Issuer", + "audience": "Audience", + "subject": "Subject", + "roleArnPlaceholder": "arn:aws:iam::123456789012:role/ProboAudit" + }, + "messages": { + "copyFailed": "Copy failed", + "copied": "Copied to clipboard", + "copiedIssuer": "Issuer copied.", + "copiedAudience": "Audience copied.", + "copiedSubject": "Subject copied.", + "copiedTerraform": "Terraform snippet copied.", + "error": "Error", + "success": "Success", + "created": "Access source created successfully." + }, + "errors": { + "roleArn": "Enter a valid IAM role ARN.", + "create": "Could not create the connector. Check the role ARN and try again.", + "disconnected": "Probo could not assume the audit role. Confirm the stack is deployed in this account and that the issuer and subject match the values shown during setup.", + "delete": "Could not delete the connector. Try again.", + "source": "Failed to create access source", + "copy": "Copy the value manually." + }, + "actions": { + "copy": "Copy", + "connect": "Connect", + "back": "Back", + "installViaCloudFormation": "Install via CloudFormation", + "installViaTerraform": "Install via Terraform", + "chooseAnotherInstallMethod": "Choose another install method" + } + }, "oauthExtraDialog": { "actions": { "continue": "Continue" }, "datadog": { "title": "Connect Datadog", "description": "Select your Datadog site, then continue to authorize access.", "siteLabel": "Datadog site", "sitePlaceholder": "Select a site" }, diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json index 6fe11b3dd8..83a7641ebe 100644 --- a/apps/console/src/_locales/fr-FR.json +++ b/apps/console/src/_locales/fr-FR.json @@ -3227,7 +3227,6 @@ "chooseAnotherMethod": "Choisir une autre méthode de connexion", "open": "Ouvrir" }, - "comingSoon": "Bientôt disponible !", "csv": { "title": "CSV", "description": "Téléversez des données CSV directement comme source d’accès." @@ -3344,6 +3343,46 @@ "connecting": "Connexion..." } }, + "createAwsAccessReviewSourcePage": { + "pageTitle": "Connecter AWS", + "title": "Connecter AWS", + "description": "Déployez le rôle d’audit. Saisissez ensuite son ARN.", + "permissionDenied": "Vous n’avez pas la permission de créer des sources d’accès.", + "fields": { + "roleArn": "ARN du rôle", + "issuer": "Émetteur", + "audience": "Audience", + "subject": "Sujet", + "roleArnPlaceholder": "arn:aws:iam::123456789012:role/ProboAudit" + }, + "messages": { + "copyFailed": "Échec de la copie", + "copied": "Copié dans le presse-papiers", + "copiedIssuer": "Émetteur copié.", + "copiedAudience": "Audience copiée.", + "copiedSubject": "Sujet copié.", + "copiedTerraform": "Extrait Terraform copié.", + "error": "Erreur", + "success": "Succès", + "created": "Source d’accès créée avec succès." + }, + "errors": { + "roleArn": "Saisissez un ARN de rôle IAM valide.", + "create": "Échec de la création du connecteur. Vérifiez l’ARN du rôle et réessayez.", + "disconnected": "Probo n’a pas pu endosser le rôle d’audit. Vérifiez que la stack est déployée dans ce compte et que l’émetteur et le sujet correspondent aux valeurs affichées lors de la configuration.", + "delete": "Impossible de supprimer le connecteur. Réessayez.", + "source": "Échec de la création de la source d’accès", + "copy": "Copiez la valeur manuellement." + }, + "actions": { + "copy": "Copier", + "connect": "Connecter", + "back": "Retour", + "installViaCloudFormation": "Installer via CloudFormation", + "installViaTerraform": "Installer via Terraform", + "chooseAnotherInstallMethod": "Choisir une autre méthode d’installation" + } + }, "oauthExtraDialog": { "actions": { "continue": "Continuer" diff --git a/apps/console/src/_locales/nl-NL.json b/apps/console/src/_locales/nl-NL.json index e69a284fed..6e87c092f2 100644 --- a/apps/console/src/_locales/nl-NL.json +++ b/apps/console/src/_locales/nl-NL.json @@ -3231,7 +3231,6 @@ "chooseAnotherMethod": "Kies een andere verbindingsmethode", "open": "Open" }, - "comingSoon": "Binnenkort beschikbaar!", "csv": { "title": "CSV", "description": "Upload CSV-gegevens direct als toegangsbron." @@ -3348,6 +3347,46 @@ "connecting": "Verbinden..." } }, + "createAwsAccessReviewSourcePage": { + "pageTitle": "AWS verbinden", + "title": "AWS verbinden", + "description": "Implementeer de auditrol. Voer daarna de ARN in.", + "permissionDenied": "U heeft geen rechten om toegangsbronnen aan te maken.", + "fields": { + "roleArn": "Rol-ARN", + "issuer": "Uitgever", + "audience": "Audience", + "subject": "Subject", + "roleArnPlaceholder": "arn:aws:iam::123456789012:role/ProboAudit" + }, + "messages": { + "copyFailed": "Kopiëren mislukt", + "copied": "Gekopieerd naar klembord", + "copiedIssuer": "Uitgever gekopieerd.", + "copiedAudience": "Audience gekopieerd.", + "copiedSubject": "Subject gekopieerd.", + "copiedTerraform": "Terraform-fragment gekopieerd.", + "error": "Fout", + "success": "Succes", + "created": "Toegangsbron succesvol aangemaakt." + }, + "errors": { + "roleArn": "Voer een geldige IAM-rol-ARN in.", + "create": "Aanmaken van de connector mislukt. Controleer de rol-ARN en probeer het opnieuw.", + "disconnected": "Probo kon de auditrol niet aannemen. Controleer of de stack in dit account is uitgerold en of de issuer en het subject overeenkomen met de waarden die tijdens de installatie zijn getoond.", + "delete": "Kan de connector niet verwijderen. Probeer het opnieuw.", + "source": "Aanmaken van toegangsbron mislukt", + "copy": "Kopieer de waarde handmatig." + }, + "actions": { + "copy": "Kopiëren", + "connect": "Verbinden", + "back": "Terug", + "installViaCloudFormation": "Installeren via CloudFormation", + "installViaTerraform": "Installeren via Terraform", + "chooseAnotherInstallMethod": "Kies een andere installatiemethode" + } + }, "oauthExtraDialog": { "actions": { "continue": "Doorgaan" diff --git a/apps/console/src/pages/organizations/access-reviews/connections/_components/ConnectMethodSplitButton.tsx b/apps/console/src/pages/organizations/access-reviews/_components/ActionSplitButton.tsx similarity index 55% rename from apps/console/src/pages/organizations/access-reviews/connections/_components/ConnectMethodSplitButton.tsx rename to apps/console/src/pages/organizations/access-reviews/_components/ActionSplitButton.tsx index 53326b82c8..bf0d610f69 100644 --- a/apps/console/src/pages/organizations/access-reviews/connections/_components/ConnectMethodSplitButton.tsx +++ b/apps/console/src/pages/organizations/access-reviews/_components/ActionSplitButton.tsx @@ -25,45 +25,64 @@ import { IconChevronDown, } from "@probo/ui"; -import type { ConnectMethod } from "../_lib/connectMethods"; - -export interface ConnectMethodAction { - id: ConnectMethod; +export interface ActionSplitButtonAction { + id: string; label: string; - onSelect: () => void; + href?: string; + onSelect?: () => void; } -interface ConnectMethodSplitButtonProps { - actions: ReadonlyArray; +interface ActionSplitButtonProps { + actions: ReadonlyArray; chooseAnotherMethodLabel: string; } -export function ConnectMethodSplitButton({ +export function ActionSplitButton({ actions, chooseAnotherMethodLabel, -}: ConnectMethodSplitButtonProps) { +}: ActionSplitButtonProps) { const [preferredAction, ...alternativeActions] = actions; if (!preferredAction) { return null; } + + const splitClassName + = alternativeActions.length > 0 ? "rounded-r-none" : undefined; + const preferredButton = preferredAction.href + ? ( + + ) + : ( + + ); + if (alternativeActions.length === 0) { - return ( - - ); + return preferredButton; } return (
- + {preferredButton} {alternativeActions.map(action => ( - - {action.label} - + action.href + ? ( + + + {action.label} + + + ) + : ( + + {action.label} + + ) ))}
diff --git a/apps/console/src/pages/organizations/access-reviews/connections/CreateAwsAccessReviewSourcePage.tsx b/apps/console/src/pages/organizations/access-reviews/connections/CreateAwsAccessReviewSourcePage.tsx new file mode 100644 index 0000000000..bf815011b4 --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/connections/CreateAwsAccessReviewSourcePage.tsx @@ -0,0 +1,392 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { usePageTitle } from "@probo/hooks"; +import { + Button, + Card, + Field, + IconSquareBehindSquare2, + Input, + PageHeader, + useToast, +} from "@probo/ui"; +import { type ChangeEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { type PreloadedQuery, usePreloadedQuery } from "react-relay"; +import { Link, useNavigate } from "react-router"; +import { ConnectionHandler, graphql } from "relay-runtime"; + +import type { accessReviewSourceMutationsCreateMutation } from "#/__generated__/core/accessReviewSourceMutationsCreateMutation.graphql"; +import type { CreateAwsAccessReviewSourcePageCreateMutation } from "#/__generated__/core/CreateAwsAccessReviewSourcePageCreateMutation.graphql"; +import type { CreateAwsAccessReviewSourcePageDeleteMutation } from "#/__generated__/core/CreateAwsAccessReviewSourcePageDeleteMutation.graphql"; +import type { CreateAwsAccessReviewSourcePageQuery } from "#/__generated__/core/CreateAwsAccessReviewSourcePageQuery.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; +import { useMutation } from "#/lib/relay/useMutation"; + +import { + ActionSplitButton, + type ActionSplitButtonAction, +} from "../_components/ActionSplitButton"; +import { + AWS_IAM_ROLE_ARN_PATTERN, + isAWSRoleARN, +} from "../dialogs/_lib/connectorSettings"; +import { createAccessReviewSourceMutation, prependCreatedSourceEdge } from "../dialogs/accessReviewSourceMutations"; + +export const createAwsAccessReviewSourcePageQuery = graphql` + query CreateAwsAccessReviewSourcePageQuery($organizationId: ID!) { + awsConnectorSetup(organizationId: $organizationId) { + issuer + audience + subject + terraformSnippet + cloudFormationQuickCreateURL + } + accessReviewDrivers { + provider + displayName + } + organization: node(id: $organizationId) { + __typename + ... on Organization { + id + canCreateSource: permission(action: "access-review:source:create") + } + } + } +`; + +const createWorkloadIdentityConnectorMutation = graphql` + mutation CreateAwsAccessReviewSourcePageCreateMutation( + $input: CreateWorkloadIdentityConnectorInput! + ) { + createWorkloadIdentityConnector(input: $input) { + connector { + id + provider + connectionStatus + } + } + } +`; + +const deleteConnectorMutation = graphql` + mutation CreateAwsAccessReviewSourcePageDeleteMutation( + $input: DeleteConnectorInput! + ) { + deleteConnector(input: $input) { + deletedConnectorId + } + } +`; + +interface CreateAwsAccessReviewSourcePageProps { + queryRef: PreloadedQuery; +} + +export function CreateAwsAccessReviewSourcePage({ + queryRef, +}: CreateAwsAccessReviewSourcePageProps) { + const { t } = useTranslation(); + const { toast } = useToast(); + const navigate = useNavigate(); + const organizationId = useOrganizationId(); + const [roleArn, setRoleArn] = useState(""); + const [isCreating, setIsCreating] = useState(false); + + usePageTitle(t("createAwsAccessReviewSourcePage.pageTitle")); + + const { organization, awsConnectorSetup, accessReviewDrivers } + = usePreloadedQuery( + createAwsAccessReviewSourcePageQuery, + queryRef, + ); + if (organization.__typename !== "Organization") { + throw new Error("Organization not found"); + } + + const awsDriver = accessReviewDrivers.find( + driver => driver.provider === "AWS", + ); + if (!awsDriver) { + throw new Error("AWS access review driver not found"); + } + + const connectionId = ConnectionHandler.getConnectionID( + organization.id, + "AccessReviewConnectionsPage_accessReviewSources", + ); + + const [createWorkloadIdentityConnector] = useMutation< + CreateAwsAccessReviewSourcePageCreateMutation + >(createWorkloadIdentityConnectorMutation); + const [deleteConnector] = useMutation< + CreateAwsAccessReviewSourcePageDeleteMutation + >(deleteConnectorMutation); + const [createAccessReviewSource] = useMutation< + accessReviewSourceMutationsCreateMutation + >(createAccessReviewSourceMutation); + + if (!organization.canCreateSource) { + return ( + +

+ {t("createAwsAccessReviewSourcePage.permissionDenied")} +

+
+ ); + } + + const copyValue = (value: string, successKey: string) => { + const onCopyFailure = () => + toast({ + title: t("createAwsAccessReviewSourcePage.messages.copyFailed"), + description: t("createAwsAccessReviewSourcePage.errors.copy"), + variant: "error", + }); + + if (!navigator.clipboard?.writeText) { + onCopyFailure(); + return; + } + + try { + navigator.clipboard.writeText(value).then( + () => + toast({ + title: t("createAwsAccessReviewSourcePage.messages.copied"), + description: t(successKey), + variant: "success", + }), + onCopyFailure, + ); + } catch { + onCopyFailure(); + } + }; + + const roleArnValid = isAWSRoleARN(roleArn); + const roleArnInvalid = roleArn.trim() !== "" && !roleArnValid; + + const onSubmit = async () => { + if (!roleArnValid) { + return; + } + + setIsCreating(true); + + try { + const created = await createWorkloadIdentityConnector( + { + variables: { + input: { + organizationId, + provider: "AWS", + awsRoleArn: roleArn.trim(), + }, + }, + }, + { errorToast: t("createAwsAccessReviewSourcePage.errors.create") }, + ); + const { id: connectorId, connectionStatus } + = created.createWorkloadIdentityConnector.connector; + + const discardConnector = () => + deleteConnector( + { variables: { input: { connectorId } } }, + { errorToast: t("createAwsAccessReviewSourcePage.errors.delete") }, + ); + + if (connectionStatus !== "CONNECTED") { + toast({ + title: t("createAwsAccessReviewSourcePage.messages.error"), + description: t( + "createAwsAccessReviewSourcePage.errors.disconnected", + ), + variant: "error", + }); + await discardConnector(); + return; + } + + try { + await createAccessReviewSource( + { + variables: { + input: { + organizationId, + connectorId, + name: awsDriver.displayName, + csvData: null, + }, + }, + updater: (store) => { + if (connectionId) { + prependCreatedSourceEdge(store, connectionId); + } + }, + }, + { errorToast: t("createAwsAccessReviewSourcePage.errors.source") }, + ); + } catch { + await discardConnector(); + return; + } + + toast({ + title: t("createAwsAccessReviewSourcePage.messages.success"), + description: t("createAwsAccessReviewSourcePage.messages.created"), + variant: "success", + }); + void navigate(`/organizations/${organizationId}/access-reviews/connections`); + } catch { + return; + } finally { + setIsCreating(false); + } + }; + + const installActions: ActionSplitButtonAction[] = []; + if (awsConnectorSetup.cloudFormationQuickCreateURL) { + installActions.push({ + id: "cloudformation", + label: t( + "createAwsAccessReviewSourcePage.actions.installViaCloudFormation", + ), + href: awsConnectorSetup.cloudFormationQuickCreateURL, + }); + } + if (awsConnectorSetup.terraformSnippet) { + installActions.push({ + id: "terraform", + label: t("createAwsAccessReviewSourcePage.actions.installViaTerraform"), + onSelect: () => + copyValue( + awsConnectorSetup.terraformSnippet, + "createAwsAccessReviewSourcePage.messages.copiedTerraform", + ), + }); + } + + return ( +
+ + {installActions.length > 0 && ( + + )} + + + +
{ + e.preventDefault(); + void onSubmit(); + }} + className="space-y-4" + > + ) => + setRoleArn(e.target.value)} + required + pattern={AWS_IAM_ROLE_ARN_PATTERN} + placeholder={t( + "createAwsAccessReviewSourcePage.fields.roleArnPlaceholder", + )} + error={ + roleArnInvalid + ? t("createAwsAccessReviewSourcePage.errors.roleArn") + : undefined + } + /> + {( + [ + { + name: "issuer", + value: awsConnectorSetup.issuer, + successKey: + "createAwsAccessReviewSourcePage.messages.copiedIssuer", + }, + { + name: "audience", + value: awsConnectorSetup.audience, + successKey: + "createAwsAccessReviewSourcePage.messages.copiedAudience", + }, + { + name: "subject", + value: awsConnectorSetup.subject, + successKey: + "createAwsAccessReviewSourcePage.messages.copiedSubject", + }, + ] as const + ).map(row => ( + +
+
+ +
+
+
+ ))} + +
+ + +
+ +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/connections/CreateAwsAccessReviewSourcePageLoader.tsx b/apps/console/src/pages/organizations/access-reviews/connections/CreateAwsAccessReviewSourcePageLoader.tsx new file mode 100644 index 0000000000..89417f06bc --- /dev/null +++ b/apps/console/src/pages/organizations/access-reviews/connections/CreateAwsAccessReviewSourcePageLoader.tsx @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Suspense, useEffect } from "react"; +import { useQueryLoader } from "react-relay"; + +import type { CreateAwsAccessReviewSourcePageQuery } from "#/__generated__/core/CreateAwsAccessReviewSourcePageQuery.graphql"; +import { PageSkeleton } from "#/components/skeletons/PageSkeleton"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +import { + CreateAwsAccessReviewSourcePage, + createAwsAccessReviewSourcePageQuery, +} from "./CreateAwsAccessReviewSourcePage"; + +export default function CreateAwsAccessReviewSourcePageLoader() { + const organizationId = useOrganizationId(); + const [queryRef, loadQuery] + = useQueryLoader( + createAwsAccessReviewSourcePageQuery, + ); + + useEffect(() => { + loadQuery({ organizationId }); + }, [loadQuery, organizationId]); + + const currentQueryRef = queryRef != null + && queryRef.variables.organizationId === organizationId + ? queryRef + : null; + + if (currentQueryRef == null) { + return ; + } + + return ( + }> + + + ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/connections/_components/AccessReviewSourceProviderListItem.tsx b/apps/console/src/pages/organizations/access-reviews/connections/_components/AccessReviewSourceProviderListItem.tsx index ada64f4d96..a00bf33ded 100644 --- a/apps/console/src/pages/organizations/access-reviews/connections/_components/AccessReviewSourceProviderListItem.tsx +++ b/apps/console/src/pages/organizations/access-reviews/connections/_components/AccessReviewSourceProviderListItem.tsx @@ -18,13 +18,15 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { Badge, ThirdPartyLogo } from "@probo/ui"; +import { ThirdPartyLogo } from "@probo/ui"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { graphql, useFragment } from "react-relay"; +import { useNavigate } from "react-router"; import type { AccessReviewSourceProviderListItem_provider$key } from "#/__generated__/core/AccessReviewSourceProviderListItem_provider.graphql"; +import { ActionSplitButton } from "../../_components/ActionSplitButton"; import { APIKeyConnectorDialog } from "../../dialogs/_components/APIKeyConnectorDialog"; import { ClientCredentialsConnectorDialog } from "../../dialogs/_components/ClientCredentialsConnectorDialog"; import { ConnectorDocumentationLink } from "../../dialogs/_components/ConnectorDocumentationLink"; @@ -38,7 +40,6 @@ import { } from "../../dialogs/_lib/connectorSettings"; import { type ConnectMethod, connectMethods } from "../_lib/connectMethods"; -import { ConnectMethodSplitButton } from "./ConnectMethodSplitButton"; import { accessReviewSourceSection } from "./variants"; const connectMethodActionLabelKey: Record = { @@ -60,6 +61,7 @@ export const accessReviewSourceProviderListItemFragment = graphql` apiKeySupported apiKeyManaged clientCredentialsSupported + workloadIdentitySupported oauth2Scopes ...APIKeyConnectorDialog_provider ...ClientCredentialsConnectorDialog_provider @@ -79,18 +81,20 @@ export function AccessReviewSourceProviderListItem({ connectionId, }: AccessReviewSourceProviderListItemProps) { const { t } = useTranslation(); + const navigate = useNavigate(); const provider = useFragment( accessReviewSourceProviderListItemFragment, providerKey, ); const { item, content, trailing } = accessReviewSourceSection(); const [activeDialog, setActiveDialog] = useState< - "apiKey" | "clientCredentials" | "datadog" | "zendesk" | null + | "apiKey" + | "clientCredentials" + | "datadog" + | "zendesk" + | null >(null); - // AWS access review is not implemented yet. - const isComingSoon = provider.provider === "AWS"; - // Every row renders the dialogs its provider can actually reach, so a list of // providers does not mount three unusable dialogs per row. const supportsAPIKey = provider.apiKeySupported || provider.apiKeyManaged; @@ -127,13 +131,17 @@ export function AccessReviewSourceProviderListItem({ setActiveDialog("clientCredentials"); break; case "GITHUB_APP": - case "WORKLOAD_IDENTITY": connectProviderProtocol( organizationId, provider.provider, method, ); break; + case "WORKLOAD_IDENTITY": + void navigate( + `/organizations/${organizationId}/access-reviews/connections/new/aws-workload-identity`, + ); + break; } }; const actions = methods.map(method => ({ @@ -155,20 +163,12 @@ export function AccessReviewSourceProviderListItem({
- {isComingSoon - ? ( - - {t("addAccessReviewSourceDialog.comingSoon")} - - ) - : ( - - )} +
{supportsAPIKey && ( = [ @@ -42,13 +43,11 @@ export function connectMethods({ apiKeySupported, apiKeyManaged, clientCredentialsSupported, + workloadIdentitySupported, }: ConnectMethodSupport): ReadonlyArray { const supportedMethods = new Set( configuredProtocols.filter( - protocol => - protocol === "WORKLOAD_IDENTITY" - || protocol === "GITHUB_APP" - || protocol === "OAUTH2", + protocol => protocol === "GITHUB_APP" || protocol === "OAUTH2", ), ); @@ -58,6 +57,9 @@ export function connectMethods({ if (clientCredentialsSupported) { supportedMethods.add("CLIENT_CREDENTIALS"); } + if (workloadIdentitySupported) { + supportedMethods.add("WORKLOAD_IDENTITY"); + } return connectMethodPreference.filter(method => supportedMethods.has(method)); } diff --git a/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts b/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts index f5e49b9be9..dcb3e818a1 100644 --- a/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts +++ b/apps/console/src/pages/organizations/access-reviews/dialogs/_lib/connectorSettings.ts @@ -96,6 +96,17 @@ export function mapAPIKeyExtraSettingToField( return null; } +// Same grammar as pkg/awsx/arn.RoleARNPattern, with the three supported +// partitions inlined so the field rejects other partitions immediately. +export const AWS_IAM_ROLE_ARN_PATTERN + = "arn:(aws-us-gov|aws-cn|aws):iam::[0-9]{12}:role(?:/[\\w+=,.@\\-]+)*/[\\w+=,.@\\-]{1,64}"; + +const awsIAMRoleARN = new RegExp(`^${AWS_IAM_ROLE_ARN_PATTERN}$`); + +export function isAWSRoleARN(value: string): boolean { + return awsIAMRoleARN.test(value.trim()); +} + export function mapClientCredentialsExtraSettingToField( provider: string, settingKey: string, diff --git a/apps/console/src/pages/organizations/access-reviews/routes.ts b/apps/console/src/pages/organizations/access-reviews/routes.ts index c6085fe196..4d8f74ec32 100644 --- a/apps/console/src/pages/organizations/access-reviews/routes.ts +++ b/apps/console/src/pages/organizations/access-reviews/routes.ts @@ -52,4 +52,11 @@ export const accessReviewRoutes = [ () => import("./connections/CreateCsvAccessReviewSourcePageLoader"), ), }, + { + path: "connections/new/aws-workload-identity", + Fallback: PageSkeleton, + Component: lazy( + () => import("./connections/CreateAwsAccessReviewSourcePageLoader"), + ), + }, ] satisfies AppRoute[]; diff --git a/contrib/claude/release/terraform-aws-audit-role.md b/contrib/claude/release/terraform-aws-audit-role.md index bc9b2bc9a4..1747bcd7ab 100644 --- a/contrib/claude/release/terraform-aws-audit-role.md +++ b/contrib/claude/release/terraform-aws-audit-role.md @@ -34,18 +34,28 @@ module-repo commit is created with GitHub's `createCommitOnBranch` mutation (same as the Homebrew tap) so it satisfies the org signed-commit rule; a local `git commit` plus `git push` would be rejected. -Consumers can pin either source: +The primary consumer address is the Terraform Registry module: + +```hcl +module "probo_audit" { + source = "getprobo/audit-role/aws" + version = "0.1.3" +} +``` + +Consumers can also pin a Git source: ```hcl # Dedicated module repo (filled by the release workflow) -source = "github.com/getprobo/terraform-aws-audit-role?ref=v0.1.0" +source = "github.com/getprobo/terraform-aws-audit-role?ref=v0.1.3" # Monorepo subdirectory -source = "github.com/getprobo/probo//contrib/terraform/aws-audit-role?ref=terraform-aws-audit-role/v0.1.0" +source = "github.com/getprobo/probo//contrib/terraform/aws-audit-role?ref=terraform-aws-audit-role/v0.1.3" ``` Connecting `getprobo/terraform-aws-audit-role` to the public Terraform -Registry is a separate step after the first `v*` tag exists there. +Registry is a separate step after the first `v*` tag exists there. The +registry address is `getprobo/audit-role/aws`. ### One-time setup (before the first release) diff --git a/contrib/cloudformation/aws-audit-role/CHANGELOG.md b/contrib/cloudformation/aws-audit-role/CHANGELOG.md index 0fb1e79aab..6e051e3372 100644 --- a/contrib/cloudformation/aws-audit-role/CHANGELOG.md +++ b/contrib/cloudformation/aws-audit-role/CHANGELOG.md @@ -5,6 +5,13 @@ documented in this file. ## Unreleased +### Changed + +- Document the connect-screen flow: Probo builds the quick-create URL, and + the customer pastes the `RoleARN` output back. +- Restore the self-hosted fallbacks: upload-a-template or Terraform, and + that a YAML file served by probod cannot drive one-click. + ## [0.1.1] - 2026-08-28 ### Changed diff --git a/contrib/cloudformation/aws-audit-role/README.md b/contrib/cloudformation/aws-audit-role/README.md index c700ee5ad1..773ac2d929 100644 --- a/contrib/cloudformation/aws-audit-role/README.md +++ b/contrib/cloudformation/aws-audit-role/README.md @@ -23,48 +23,56 @@ SOFTWARE. # CloudFormation setup artifacts [`aws-audit-role.yaml`](aws-audit-role.yaml) is the source of truth for the AWS -connector's customer-side install. It creates an IAM OIDC provider for a Probo -organization's issuer, a read-only `ProboAudit` role trusting exactly that -issuer and subject, and — optionally — a service-managed StackSet that repeats -both in every member account of the organization. +connector's customer-side install. The template creates an IAM OIDC provider +for the issuer of a Probo organization. It also creates a read-only +`ProboAudit` role that trusts that issuer and subject. Optionally, a +service-managed StackSet repeats both in every member account of the +organization. Terraform parity lives in [`../../terraform/aws-audit-role`](../../terraform/aws-audit-role). ## What the customer runs -Deploy one stack in the AWS account that you connect to Probo. +Open the AWS connect screen in Probo. The screen gives a CloudFormation link +with the issuer, subject, and role name already filled. Click that link. -Leave `DeployToOrganization` set to No. The stack creates the OIDC provider -and the `ProboAudit` role in that account only. +Leave `DeployToOrganization` set to No. Create the stack in the AWS account +that you connect to Probo. Copy the `RoleARN` output. Paste that ARN into +Probo. + +The stack creates the OIDC provider and the `ProboAudit` role in that account +only. ### Optional: cover the organization later The template can create a service-managed StackSet. That StackSet creates the -same provider and role in every member account, including accounts added later. +same provider and role in every member account. Accounts that join later get +the same resources. -Probo does not use those member roles yet. Set `DeployToOrganization` to Yes -only if you will cover the organization later. Then set -`OrganizationalUnitIds` to at least the organization root (`r-xxxx`). Run -that stack from the management account or from a CloudFormation delegated -administrator. +Probo does not use those member roles yet. If you will cover the organization +later, set `DeployToOrganization` to Yes. Then set `OrganizationalUnitIds` to +at least the organization root (`r-xxxx`). Run that stack from the management +account or from a CloudFormation delegated administrator. A service-managed StackSet needs CloudFormation trusted access in the organization. Without trusted access, the stack fails on the StackSet resource. ## Publishing for one-click install -CloudFormation's quick-create console accepts a `templateURL` **on S3 only** — -not a GitHub raw URL, not an arbitrary HTTPS host, not a GitHub Release -asset. +Probo publishes the template at +`https://probo-cloudformation-template.s3.us-east-2.amazonaws.com/aws-audit-role.yaml`. +The CloudFormation quick-create console accepts a `templateURL` **on S3 +only**. A GitHub raw URL, an arbitrary HTTPS host, and a GitHub Release asset +do not work. -The customer's CloudFormation fetches that object. A bucket that only Probo -IAM can read does **not** work: the customer has no credentials there, and -you cannot list every customer account in a bucket policy. +CloudFormation in the customer account fetches that object. A bucket that +only Probo IAM can read does **not** work. The customer has no credentials +there, and you cannot list every customer account in a bucket policy. The object (or its prefix) must allow anonymous `s3:GetObject`. The rest of the bucket can stay private. Block Public Access must allow that GetObject. The object is still public. A presigned URL can point at a private object, -but it expires and is a poor fit for runbooks and change tickets. +but it expires. Runbooks and change tickets need a URL that does not expire. One-click therefore requires the template in a Probo-owned bucket with anonymous read on that key: @@ -75,56 +83,66 @@ aws s3 cp contrib/cloudformation/aws-audit-role/aws-audit-role.yaml \ --cache-control "max-age=300" ``` -Publish under a stable key. The URL ends up in customer runbooks and change -tickets, and a moved object breaks a stack update they attempt months later. +Publish under a stable key. The URL is recorded in customer runbooks and +change tickets. A moved object breaks a stack update that they attempt months +later. -Self-hosted deployments that cannot publish to S3 give their customers the -upload-a-template path or the Terraform module instead. Serving the YAML from -probod is fine for download but **cannot** drive one-click. +If a self-hosted deployment cannot publish to S3, give the customers the +upload-a-template path or the Terraform module instead. You can serve the +YAML from probod for download. That URL cannot drive one-click. ## The quick-create link +Probo builds this URL. The customer must not assemble it by hand. The link +opens the console in `us-east-1` with `stackName=probo-audit` and with +`ProboIssuerURL`, `ProboSubject`, and `RoleName` already filled. IAM is +global, so that console region is not the home region of the account. + ``` -https://.console.aws.amazon.com/cloudformation/home - ?region= +https://us-east-1.console.aws.amazon.com/cloudformation/home + ?region=us-east-1 #/stacks/quickcreate ?templateURL= &stackName=probo-audit ¶m_ProboIssuerURL= ¶m_ProboSubject= + ¶m_RoleName=ProboAudit ``` Every Probo-derived parameter must be prefilled. AWS compares the issuer URL -case-sensitively, and the issuer's last path segment is a mixed-case identifier, -so **the customer must never have to retype it** — a single flipped character -produces an `AccessDenied` with nothing in it that names the cause. +with case sensitivity. The last path segment of the issuer is a mixed-case +identifier. The customer must never retype it. One flipped character produces +an `AccessDenied` with nothing in it that names the cause. + +If the customer changes `RoleName`, the ARN that they paste into Probo +changes with it. The audience is not a parameter. Probo always mints +`sts.amazonaws.com`. The connector cannot use another value. -`RoleName` is left to its default unless the customer has a reason to change -it; if they do, the same name must be recorded on the connector. The audience -is not a parameter: Probo always mints `sts.amazonaws.com` and the connector -cannot be told another value. +## Probing an install -## Verifying an install +When the customer pastes the ARN, Probo probes the install by assuming the +role. Isolation is the per-organization issuer. A foreign token fails at the +provider-match step before STS evaluates any trust-policy condition. The +`sub` and `aud` conditions in this template are IAM hygiene. Probo does not +read them back. -Probo probes the install by assuming the role. Isolation is the -per-organization issuer: a foreign token fails at the provider-match step -before STS evaluates any trust-policy condition. The `sub` and `aud` -conditions in this template are IAM hygiene; Probo does not read them back. +If the probe fails, make sure that the stack exists in that account. Make +sure that the issuer and subject match the values on the connect screen. ## Editing the template Two things are easy to break and produce opaque failures: - **The trust policy is a JSON string, not a YAML mapping.** Its condition keys - embed the issuer, and CloudFormation cannot use an intrinsic function as a - mapping key. Converting it back to a mapping compiles and silently stops - templating the keys. + embed the issuer. CloudFormation cannot use an intrinsic function as a + mapping key. If you convert it back to a mapping, the template compiles and + the keys no longer get substitutions. - **The member-account template is embedded in the StackSet's `TemplateBody`.** Inside that `Fn::Sub`, `${!Foo}` renders as the literal `${Foo}` for the inner template to resolve per account, while `${Foo}` is substituted once here. - Dropping an escape bakes the management account's ID into every member - account's trust policy. + If you drop an escape, every member-account trust policy gets the management + account ID. The read-only additions policy is duplicated between the stack's own role and -the embedded template; CloudFormation offers no way to share a fragment between -the two. Change both. +the embedded template. CloudFormation offers no way to share a fragment +between the two. Change both. diff --git a/contrib/cloudformation/aws-audit-role/aws-audit-role.yaml b/contrib/cloudformation/aws-audit-role/aws-audit-role.yaml index 36cb3c70ca..82644c5970 100644 --- a/contrib/cloudformation/aws-audit-role/aws-audit-role.yaml +++ b/contrib/cloudformation/aws-audit-role/aws-audit-role.yaml @@ -110,8 +110,8 @@ Parameters: AllowedPattern: '^[\w+=,.@-]+$' ConstraintDescription: must be a valid IAM role name Description: >- - Name of the role Probo assumes. The same name is used in every account, - so changing it here means changing it in Probo too. + Name of the role Probo assumes. The same name is used in every account. + If you change it, the RoleARN output changes. Paste that ARN into Probo. CreateOIDCProvider: Type: String @@ -408,7 +408,7 @@ Resources: Outputs: RoleARN: - Description: "ARN of the role Probo assumes in this account" + Description: "ARN of the role Probo assumes. Paste this value into Probo." Value: !GetAtt ProboAuditRole.Arn RoleName: diff --git a/contrib/helm/charts/probo/templates/deployment.yaml b/contrib/helm/charts/probo/templates/deployment.yaml index 4e4e253dfb..efba35376a 100644 --- a/contrib/helm/charts/probo/templates/deployment.yaml +++ b/contrib/helm/charts/probo/templates/deployment.yaml @@ -181,6 +181,14 @@ spec: value: {{ required "probo.identityFederation.previousSigningKeyKid is required when probo.identityFederation.previousSigningKey is set" .Values.probo.identityFederation.previousSigningKeyKid | quote }} {{- end }} {{- end }} + {{- if .Values.probo.identityFederation.cloudformationTemplateUrl }} + - name: PROBOD_IDENTITY_FEDERATION_CLOUDFORMATION_TEMPLATE_URL + value: {{ .Values.probo.identityFederation.cloudformationTemplateUrl | quote }} + {{- end }} + {{- if .Values.probo.identityFederation.terraformModuleSource }} + - name: PROBOD_IDENTITY_FEDERATION_TERRAFORM_MODULE_SOURCE + value: {{ .Values.probo.identityFederation.terraformModuleSource | quote }} + {{- end }} - name: PROBOD_AUTH_PASSWORD_ITERATIONS value: {{ .Values.probo.auth.passwordIterations | quote }} - name: PROBOD_ITAM_DEVICE_ENROLLMENT_TOKEN_VALIDITY diff --git a/contrib/helm/charts/probo/values.yaml b/contrib/helm/charts/probo/values.yaml index 9cb9232323..48e406d47d 100644 --- a/contrib/helm/charts/probo/values.yaml +++ b/contrib/helm/charts/probo/values.yaml @@ -233,6 +233,10 @@ probo: # REQUIRED when previousSigningKey is set, and it must differ from # signingKeyKid. previousSigningKeyKid: "" + # Public S3 HTTPS URL used to build the AWS console quick-create link. + cloudformationTemplateUrl: "https://probo-cloudformation-template.s3.us-east-2.amazonaws.com/aws-audit-role.yaml" + # Terraform module address copied into the AWS connector install snippet. + terraformModuleSource: "getprobo/audit-role/aws" # CORS configuration cors: diff --git a/contrib/terraform/aws-audit-role/CHANGELOG.md b/contrib/terraform/aws-audit-role/CHANGELOG.md index ffae6799ce..28715aae06 100644 --- a/contrib/terraform/aws-audit-role/CHANGELOG.md +++ b/contrib/terraform/aws-audit-role/CHANGELOG.md @@ -1,10 +1,16 @@ # Changelog -All notable changes to the Terraform `aws-audit-role` module will be -documented in this file. +All notable changes to the Terraform `getprobo/audit-role/aws` module will +be documented in this file. ## Unreleased +### Changed + +- Document the public registry address `getprobo/audit-role/aws` as the + module source. The connector now records a role ARN, not an account id + and a role name. + ## [0.1.3] - 2026-08-28 ### Changed diff --git a/contrib/terraform/aws-audit-role/README.md b/contrib/terraform/aws-audit-role/README.md index f43605a8ec..6681923617 100644 --- a/contrib/terraform/aws-audit-role/README.md +++ b/contrib/terraform/aws-audit-role/README.md @@ -20,7 +20,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --> -# `aws-audit-role` +# `getprobo/audit-role/aws` Grants Probo read-only audit access to one AWS account through OIDC web identity federation. Probo holds no credential for the account: it presents a @@ -33,16 +33,17 @@ for teams that would rather not run a stack they did not write. ## Usage -Copy the two Probo values out of the connector setup screen. AWS compares the -issuer URL case-sensitively and its last path segment is a mixed-case -identifier, so paste it rather than retyping it. +Copy the issuer URL and the subject out of the connector setup screen. AWS +compares the issuer URL case-sensitively and its last path segment is a +mixed-case identifier, so paste it rather than retyping it. ```hcl module "probo_audit" { - source = "github.com/getprobo/probo//contrib/terraform/aws-audit-role" + source = "getprobo/audit-role/aws" - probo_issuer_url = "https://proboidentity.com/org/e5IaD7ibAAEAAAAAAZZ9aR_Oq_Npymhg" + probo_issuer_url = "https://proboidentity.com/e5IaD7ibAAEAAAAAAZZ9aR_Oq_Npymhg" probo_subject = "e5IaD7ibAAEAAAAAAZZ9aR_Oq_Npymhg" + role_name = "ProboAudit" } output "probo_role_arn" { @@ -50,8 +51,14 @@ output "probo_role_arn" { } ``` -Give Probo the `account_id` and `role_name` outputs when you create the -connector. +Give Probo the `role_arn` output when you create the connector. + +## Verifying an install + +Probo probes the install by assuming the role. Isolation is the +per-organization issuer: a foreign token fails at the provider-match step +before STS evaluates any trust-policy condition. The `sub` and `aud` +conditions in this module are IAM hygiene; Probo does not read them back. ## Covering a whole organization @@ -67,12 +74,13 @@ Use one provider alias per account with a role you already have there: ```hcl module "probo_audit_member" { for_each = toset(var.member_account_ids) - source = "github.com/getprobo/probo//contrib/terraform/aws-audit-role" + source = "getprobo/audit-role/aws" providers = { aws = aws.member[each.key] } probo_issuer_url = var.probo_issuer_url probo_subject = var.probo_subject + role_name = "ProboAudit" grant_organizations_read = false } @@ -107,7 +115,8 @@ are the source of truth. ## Notes - **The role must have the same name in every account.** Probo stores one role - name per connector and assumes it everywhere. + ARN per connector and reviews only that account today. Use the same name so + the roles stay ready when org-wide coverage ships. - **`thumbprint_list` is deliberately unset**, unlike the CloudFormation template which must supply a placeholder. AWS ignores it for publicly trusted certificates, so pinning one only produces a perpetual diff. diff --git a/e2e/console/aws_connector_test.go b/e2e/console/aws_connector_test.go new file mode 100644 index 0000000000..b151d4ee96 --- /dev/null +++ b/e2e/console/aws_connector_test.go @@ -0,0 +1,344 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package console_test + +import ( + "encoding/json" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/testutil" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/identityfederation" +) + +const ( + awsFixtureRoleARN = "arn:aws:iam::123456789012:role/ProboAudit" + awsSecondRoleARN = "arn:aws:iam::111111111111:role/ProboAudit" +) + +const awsConnectorSetupQuery = ` + query($organizationId: ID!) { + awsConnectorSetup(organizationId: $organizationId) { + issuer + audience + subject + suggestedRoleName + terraformSnippet + cloudFormationQuickCreateURL + } + } +` + +const createWorkloadIdentityConnectorMutation = ` + mutation($input: CreateWorkloadIdentityConnectorInput!) { + createWorkloadIdentityConnector(input: $input) { + connector { + id + provider + protocol + } + } + } +` + +// Selected apart from createWorkloadIdentityConnectorMutation: connectionStatus +// probes the provider live, so only the tests that assert on it should pay for +// the round trip. +const createWorkloadIdentityConnectorWithStatusMutation = ` + mutation($input: CreateWorkloadIdentityConnectorInput!) { + createWorkloadIdentityConnector(input: $input) { + connector { + id + connectionStatus + } + } + } +` + +const organizationConnectorStatusQuery = ` + query($id: ID!) { + node(id: $id) { + ... on Organization { + connectors { + id + connectionStatus + } + } + } + } +` + +type awsConnectorSetupResult struct { + AWSConnectorSetup struct { + Issuer string `json:"issuer"` + Audience string `json:"audience"` + Subject string `json:"subject"` + SuggestedRoleName string `json:"suggestedRoleName"` + TerraformSnippet string `json:"terraformSnippet"` + CloudFormationQuickCreateURL string `json:"cloudFormationQuickCreateURL"` + } `json:"awsConnectorSetup"` +} + +type createWorkloadIdentityConnectorResult struct { + CreateWorkloadIdentityConnector struct { + Connector struct { + ID string `json:"id"` + Provider string `json:"provider"` + Protocol string `json:"protocol"` + } `json:"connector"` + } `json:"createWorkloadIdentityConnector"` +} + +type createWorkloadIdentityConnectorWithStatusResult struct { + CreateWorkloadIdentityConnector struct { + Connector struct { + ID string `json:"id"` + ConnectionStatus string `json:"connectionStatus"` + } `json:"connector"` + } `json:"createWorkloadIdentityConnector"` +} + +type organizationConnectorStatusResult struct { + Node struct { + Connectors []struct { + ID string `json:"id"` + ConnectionStatus string `json:"connectionStatus"` + } `json:"connectors"` + } `json:"node"` +} + +func TestAWSConnectorSetup(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + orgID := owner.GetOrganizationID().String() + + var result awsConnectorSetupResult + + err := owner.Execute(awsConnectorSetupQuery, map[string]any{ + "organizationId": orgID, + }, &result) + require.NoError(t, err) + + setup := result.AWSConnectorSetup + assert.Contains(t, setup.Issuer, orgID) + assert.Equal(t, identityfederation.AudienceAWS, setup.Audience) + assert.Equal(t, orgID, setup.Subject) + assert.Equal(t, coredata.DefaultAWSRoleName, setup.SuggestedRoleName) + assert.Contains(t, setup.TerraformSnippet, setup.Issuer) + assert.Contains(t, setup.TerraformSnippet, setup.Subject) + assert.Contains(t, setup.TerraformSnippet, cloudaws.DefaultTerraformModuleSource) + assert.Contains(t, setup.TerraformSnippet, coredata.DefaultAWSRoleName) + assert.Contains(t, setup.CloudFormationQuickCreateURL, url.QueryEscape(setup.Issuer)) + assert.Contains(t, setup.CloudFormationQuickCreateURL, url.QueryEscape(coredata.DefaultAWSRoleName)) +} + +func TestCreateWorkloadIdentityConnector(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + orgID := owner.GetOrganizationID().String() + + var result createWorkloadIdentityConnectorResult + + err := owner.Execute(createWorkloadIdentityConnectorMutation, map[string]any{ + "input": map[string]any{ + "organizationId": orgID, + "provider": "AWS", + "awsRoleArn": awsFixtureRoleARN, + }, + }, &result) + require.NoError(t, err) + + connector := result.CreateWorkloadIdentityConnector.Connector + assert.NotEmpty(t, connector.ID) + assert.Equal(t, "AWS", connector.Provider) + assert.Equal(t, "WORKLOAD_IDENTITY", connector.Protocol) + + t.Run("allows a second connector for the same provider", func(t *testing.T) { + t.Parallel() + + var second createWorkloadIdentityConnectorResult + + err := owner.Execute(createWorkloadIdentityConnectorMutation, map[string]any{ + "input": map[string]any{ + "organizationId": orgID, + "provider": "AWS", + "awsRoleArn": awsSecondRoleARN, + }, + }, &second) + require.NoError(t, err) + + secondID := second.CreateWorkloadIdentityConnector.Connector.ID + assert.NotEmpty(t, secondID) + assert.NotEqual(t, connector.ID, secondID) + }) +} + +func TestCreateWorkloadIdentityConnector_InvalidRoleARN(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + bogus := "arn:aws:iam::123456789012:user/alice" + + err := owner.Execute(createWorkloadIdentityConnectorMutation, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "provider": "AWS", + "awsRoleArn": bogus, + }, + }, &createWorkloadIdentityConnectorResult{}) + testutil.RequireErrorCode(t, err, "INVALID") + assert.NotContains(t, err.Error(), bogus) + assert.NotContains(t, err.Error(), "alice") + assert.NotContains(t, strings.ToLower(err.Error()), "arn:aws") +} + +// A role Probo cannot assume is reported as DISCONNECTED rather than as an +// error, so the response now travels to the client on the failure path too: +// assert the redaction against the whole payload, not just an error message. +func TestConnectorConnectionStatus_AssumeRoleFailure(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + orgID := owner.GetOrganizationID().String() + + resp, err := owner.Do(createWorkloadIdentityConnectorWithStatusMutation, map[string]any{ + "input": map[string]any{ + "organizationId": orgID, + "provider": "AWS", + "awsRoleArn": awsFixtureRoleARN, + }, + }) + require.NoError(t, err) + + var created createWorkloadIdentityConnectorWithStatusResult + require.NoError(t, json.Unmarshal(resp.Data, &created)) + + connector := created.CreateWorkloadIdentityConnector.Connector + require.NotEmpty(t, connector.ID) + assert.Equal(t, "DISCONNECTED", connector.ConnectionStatus) + + payload := resp.DataString() + assert.NotContains(t, payload, awsFixtureRoleARN) + assert.NotContains(t, payload, "arn:aws") + assert.NotContains(t, strings.ToLower(payload), "accessdenied") +} + +func TestAWSConnector_RBAC(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + orgID := owner.GetOrganizationID().String() + + t.Run("viewer cannot read setup", func(t *testing.T) { + t.Parallel() + + err := viewer.Execute(awsConnectorSetupQuery, map[string]any{ + "organizationId": orgID, + }, &awsConnectorSetupResult{}) + testutil.RequireForbiddenError(t, err, "viewer should not be able to read aws connector setup") + }) + + t.Run("viewer cannot create connector", func(t *testing.T) { + t.Parallel() + + err := viewer.Execute(createWorkloadIdentityConnectorMutation, map[string]any{ + "input": map[string]any{ + "organizationId": orgID, + "provider": "AWS", + "awsRoleArn": awsFixtureRoleARN, + }, + }, &createWorkloadIdentityConnectorResult{}) + testutil.RequireForbiddenError(t, err, "viewer should not be able to create aws connector") + }) + + // A viewer may list connectors but not read one, so the status field is + // what it is refused, not the listing around it. + t.Run("viewer cannot read connection status", func(t *testing.T) { + t.Parallel() + + var created createWorkloadIdentityConnectorResult + + err := owner.Execute(createWorkloadIdentityConnectorMutation, map[string]any{ + "input": map[string]any{ + "organizationId": orgID, + "provider": "AWS", + "awsRoleArn": awsFixtureRoleARN, + }, + }, &created) + require.NoError(t, err) + + err = viewer.Execute(organizationConnectorStatusQuery, map[string]any{ + "id": orgID, + }, &organizationConnectorStatusResult{}) + testutil.RequireForbiddenError(t, err, "viewer should not be able to read aws connector status") + }) +} + +func TestAWSConnector_TenantIsolation(t *testing.T) { + t.Parallel() + + org1 := testutil.NewClient(t, testutil.RoleOwner) + org2 := testutil.NewClient(t, testutil.RoleOwner) + org1ID := org1.GetOrganizationID().String() + + err := org1.Execute(createWorkloadIdentityConnectorMutation, map[string]any{ + "input": map[string]any{ + "organizationId": org1ID, + "provider": "AWS", + "awsRoleArn": awsFixtureRoleARN, + }, + }, &createWorkloadIdentityConnectorResult{}) + require.NoError(t, err) + + t.Run("cannot read setup for another organization", func(t *testing.T) { + t.Parallel() + + err := org2.Execute(awsConnectorSetupQuery, map[string]any{ + "organizationId": org1ID, + }, &awsConnectorSetupResult{}) + testutil.RequireForbiddenError(t, err, "org B should not read org A's aws connector setup") + }) + + t.Run("cannot create connector in another organization", func(t *testing.T) { + t.Parallel() + + err := org2.Execute(createWorkloadIdentityConnectorMutation, map[string]any{ + "input": map[string]any{ + "organizationId": org1ID, + "provider": "AWS", + "awsRoleArn": awsFixtureRoleARN, + }, + }, &createWorkloadIdentityConnectorResult{}) + testutil.RequireForbiddenError(t, err, "org B should not create a connector in org A") + }) + + t.Run("cannot read connection status from another organization", func(t *testing.T) { + t.Parallel() + + err := org2.Execute(organizationConnectorStatusQuery, map[string]any{ + "id": org1ID, + }, &organizationConnectorStatusResult{}) + testutil.RequireForbiddenError(t, err, "org B should not read org A's connector status") + }) +} diff --git a/e2e/console/connector_test.go b/e2e/console/connector_test.go index 968d4ceb9d..bf7b6d407f 100644 --- a/e2e/console/connector_test.go +++ b/e2e/console/connector_test.go @@ -53,6 +53,12 @@ func TestAccessReviewDrivers(t *testing.T) { label required } + workloadIdentitySupported + workloadIdentityExtraSettings { + key + label + required + } } } ` @@ -74,6 +80,8 @@ func TestAccessReviewDrivers(t *testing.T) { ClientCredentialsSupported bool `json:"clientCredentialsSupported"` APIKeyExtraSettings []settingInfo `json:"apiKeyExtraSettings"` ClientCredentialsExtraSettings []settingInfo `json:"clientCredentialsExtraSettings"` + WorkloadIdentitySupported bool `json:"workloadIdentitySupported"` + WorkloadIdentityExtraSettings []settingInfo `json:"workloadIdentityExtraSettings"` } `json:"accessReviewDrivers"` } @@ -86,15 +94,19 @@ func TestAccessReviewDrivers(t *testing.T) { protocolsByProvider := make(map[string][]string) apiKeySettingKeys := make(map[string][]string) clientCredentialsSettingKeys := make(map[string][]string) + workloadIdentitySettingKeys := make(map[string][]string) + workloadIdentitySupported := make(map[string]bool) for _, info := range result.AccessReviewDrivers { assert.NotEmpty(t, info.Provider) assert.NotEmpty(t, info.DisplayName) assert.NotNil(t, info.APIKeyExtraSettings) assert.NotNil(t, info.ClientCredentialsExtraSettings) + assert.NotNil(t, info.WorkloadIdentityExtraSettings) providerNames[info.Provider] = true docURLByProvider[info.Provider] = info.DocumentationURL protocolsByProvider[info.Provider] = info.ConfiguredProtocols + workloadIdentitySupported[info.Provider] = info.WorkloadIdentitySupported assert.Equal(t, slices.Contains(info.ConfiguredProtocols, "OAUTH2"), info.OAuthConfigured) for _, s := range info.APIKeyExtraSettings { @@ -104,11 +116,20 @@ func TestAccessReviewDrivers(t *testing.T) { for _, s := range info.ClientCredentialsExtraSettings { clientCredentialsSettingKeys[info.Provider] = append(clientCredentialsSettingKeys[info.Provider], s.Key) } + + for _, s := range info.WorkloadIdentityExtraSettings { + workloadIdentitySettingKeys[info.Provider] = append(workloadIdentitySettingKeys[info.Provider], s.Key) + } } assert.True(t, providerNames["BREX"], "expected BREX provider to be present") assert.True(t, providerNames["HUBSPOT"], "expected HUBSPOT provider to be present") + assert.True(t, providerNames["AWS"], "expected AWS provider to be present when identity federation is enabled") assert.Equal(t, []string{"OAUTH2"}, protocolsByProvider["GITHUB"]) + assert.True(t, workloadIdentitySupported["AWS"]) + assert.Equal(t, []string{"roleArn"}, workloadIdentitySettingKeys["AWS"]) + assert.False(t, workloadIdentitySupported["BREX"]) + assert.Empty(t, workloadIdentitySettingKeys["BREX"]) // 1Password is the only provider offering both connect paths, and each path // needs different settings: the SCIM-bridge driver behind the API key, the @@ -136,14 +157,13 @@ func TestAccessReviewDrivers(t *testing.T) { } // AWS carries the null case because it is the one provider that cannot - // acquire a doc page by being written: access review for it is not - // implemented, and the console still renders it as coming soon. Any - // provider picked here purely for being undocumented today gets documented - // eventually and breaks this assertion, as BREX did. + // acquire a doc page by being written. Any provider picked here purely for + // being undocumented today gets documented eventually and breaks this + // assertion, as BREX did. // // Contains first: a bare Nil on a missing key passes whether or not the - // field is really null, which would let the null path rot unnoticed. AWS is - // a workload identity provider, so the catalog never skips it. + // field is really null, which would let the null path rot unnoticed. E2E + // enables identity federation, so AWS is in the catalog. require.Contains(t, docURLByProvider, "AWS") assert.Nil(t, docURLByProvider["AWS"], "AWS has no doc page, documentationUrl must be null") diff --git a/e2e/mcp/aws_connector_test.go b/e2e/mcp/aws_connector_test.go new file mode 100644 index 0000000000..c9eaa36ace --- /dev/null +++ b/e2e/mcp/aws_connector_test.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package mcp_test + +import ( + "encoding/json" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/testutil" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/identityfederation" +) + +const awsFixtureRoleARN = "arn:aws:iam::123456789012:role/ProboAudit" + +func TestMCP_AWSConnector(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + mc := testutil.NewMCPClient(t, owner) + orgID := owner.GetOrganizationID().String() + + var setupResult struct { + Setup struct { + Issuer string `json:"issuer"` + Audience string `json:"audience"` + Subject string `json:"subject"` + SuggestedRoleName string `json:"suggested_role_name"` + TerraformSnippet string `json:"terraform_snippet"` + CloudFormationQuickCreateURL string `json:"cloud_formation_quick_create_url"` + } `json:"setup"` + } + mc.CallToolInto("awsConnectorSetup", map[string]any{ + "organization_id": orgID, + }, &setupResult) + assert.Contains(t, setupResult.Setup.Issuer, orgID) + assert.Equal(t, identityfederation.AudienceAWS, setupResult.Setup.Audience) + assert.Equal(t, orgID, setupResult.Setup.Subject) + assert.Equal(t, coredata.DefaultAWSRoleName, setupResult.Setup.SuggestedRoleName) + assert.Contains(t, setupResult.Setup.TerraformSnippet, setupResult.Setup.Issuer) + assert.Contains(t, setupResult.Setup.TerraformSnippet, cloudaws.DefaultTerraformModuleSource) + assert.Contains(t, setupResult.Setup.TerraformSnippet, coredata.DefaultAWSRoleName) + assert.Contains(t, setupResult.Setup.CloudFormationQuickCreateURL, url.QueryEscape(setupResult.Setup.Issuer)) + assert.Contains(t, setupResult.Setup.CloudFormationQuickCreateURL, url.QueryEscape(coredata.DefaultAWSRoleName)) + + // MCP has no lazy field resolution, so the create tool probes the role and + // reports the verdict on the connector it returns. A role Probo cannot + // assume is DISCONNECTED, not a tool error, so the payload reaches the + // client on the failure path and has to stay free of AWS detail. + tr := mc.CallTool("createWorkloadIdentityConnector", map[string]any{ + "organization_id": orgID, + "provider": "AWS", + "aws_role_arn": awsFixtureRoleARN, + }) + require.False(t, tr.IsError, "createWorkloadIdentityConnector returned error: %v", tr.Content) + require.NotEmpty(t, tr.Content) + + var payload string + + require.NoError(t, json.Unmarshal(tr.Content[0].Text, &payload)) + + var createResult struct { + Connector struct { + ID string `json:"id"` + Provider string `json:"provider"` + Protocol string `json:"protocol"` + ConnectionStatus string `json:"connection_status"` + } `json:"connector"` + } + + require.NoError(t, json.Unmarshal([]byte(payload), &createResult)) + require.NotEmpty(t, createResult.Connector.ID) + assert.Equal(t, "AWS", createResult.Connector.Provider) + assert.Equal(t, "WORKLOAD_IDENTITY", createResult.Connector.Protocol) + assert.Equal(t, "DISCONNECTED", createResult.Connector.ConnectionStatus) + + assert.NotContains(t, payload, awsFixtureRoleARN) + assert.NotContains(t, payload, "arn:aws") + assert.NotContains(t, strings.ToLower(payload), "accessdenied") +} + +func TestMCP_AWSConnector_RBAC(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + viewer := testutil.NewClientInOrg(t, testutil.RoleViewer, owner) + viewerMC := testutil.NewMCPClient(t, viewer) + orgID := owner.GetOrganizationID().String() + + msg := viewerMC.CallToolExpectToolError("awsConnectorSetup", map[string]any{ + "organization_id": orgID, + }) + assert.Contains(t, msg, "permission denied") + + // Connection status rides on the connector the create tool returns, and MCP + // exposes no other way to reach a connector, so refusing create is what + // keeps a viewer away from the status too. + msg = viewerMC.CallToolExpectToolError("createWorkloadIdentityConnector", map[string]any{ + "organization_id": orgID, + "provider": "AWS", + "aws_role_arn": awsFixtureRoleARN, + }) + assert.Contains(t, msg, "permission denied") +} + +func TestMCP_AWSConnector_TenantIsolation(t *testing.T) { + t.Parallel() + + org1 := testutil.NewClient(t, testutil.RoleOwner) + org2 := testutil.NewClient(t, testutil.RoleOwner) + org2MC := testutil.NewMCPClient(t, org2) + org1ID := org1.GetOrganizationID().String() + + msg := org2MC.CallToolExpectToolError("awsConnectorSetup", map[string]any{ + "organization_id": org1ID, + }) + assert.NotEmpty(t, msg) + + msg = org2MC.CallToolExpectToolError("createWorkloadIdentityConnector", map[string]any{ + "organization_id": org1ID, + "provider": "AWS", + "aws_role_arn": awsFixtureRoleARN, + }) + assert.NotEmpty(t, msg) +} diff --git a/packages/n8n-node/nodes/Probo/actions/accessReviewSource/create.operation.ts b/packages/n8n-node/nodes/Probo/actions/accessReviewSource/create.operation.ts new file mode 100644 index 0000000000..e80f3f4bfe --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/accessReviewSource/create.operation.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + displayOptions: { + show: { + resource: ['accessReviewSource'], + operation: ['create'], + }, + }, + default: '', + description: 'The ID of the organization', + required: true, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + displayOptions: { + show: { + resource: ['accessReviewSource'], + operation: ['create'], + }, + }, + default: '', + description: 'The name of the access source', + required: true, + }, + { + displayName: 'AWS Role ARN', + name: 'awsRoleArn', + type: 'string', + displayOptions: { + show: { + resource: ['accessReviewSource'], + operation: ['create'], + }, + }, + default: '', + description: 'IAM role ARN, including partition, account, and role name', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; + const name = this.getNodeParameter('name', itemIndex) as string; + const awsRoleArn = this.getNodeParameter('awsRoleArn', itemIndex) as string; + + const createConnectorQuery = ` + mutation CreateWorkloadIdentityConnector($input: CreateWorkloadIdentityConnectorInput!) { + createWorkloadIdentityConnector(input: $input) { + connector { + id + provider + protocol + connectionStatus + createdAt + } + } + } + `; + + const connectorInput: Record = { + organizationId, + provider: 'AWS', + awsRoleArn, + }; + + const connectorResponse = await proboApiRequest.call(this, createConnectorQuery, { + input: connectorInput, + }); + + const connectorPayload = (connectorResponse.data as IDataObject | undefined) + ?.createWorkloadIdentityConnector as IDataObject | undefined; + const connector = connectorPayload?.connector as IDataObject | undefined; + const connectorId = connector?.id as string | undefined; + if (!connectorId) { + throw new NodeOperationError(this.getNode(), 'Workload identity connector was not created'); + } + + const connectionStatus = connector?.connectionStatus as string | undefined; + + const createSourceQuery = ` + mutation CreateAccessReviewSource($input: CreateAccessReviewSourceInput!) { + createAccessReviewSource(input: $input) { + created + accessReviewSourceEdge { + node { + id + name + connectorId + createdAt + } + } + } + } + `; + + const deleteConnectorQuery = ` + mutation DeleteConnector($input: DeleteConnectorInput!) { + deleteConnector(input: $input) { + deletedConnectorId + } + } + `; + + try { + if (connectionStatus !== 'CONNECTED') { + throw new NodeOperationError( + this.getNode(), + `Connector is ${connectionStatus ?? 'in an unknown state'}`, + { itemIndex }, + ); + } + + const sourceResponse = await proboApiRequest.call(this, createSourceQuery, { + input: { + organizationId, + name, + connectorId, + }, + }); + + return { + json: sourceResponse, + pairedItem: { item: itemIndex }, + }; + } catch (error) { + try { + await proboApiRequest.call(this, deleteConnectorQuery, { + input: { connectorId }, + }); + } catch (cleanupError) { + const cleanupMessage = + cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + + throw new NodeOperationError( + this.getNode(), + error instanceof Error ? error : new Error(String(error)), + { + itemIndex, + description: `Cannot delete leftover connector ${connectorId}: ${cleanupMessage}`, + }, + ); + } + + throw new NodeOperationError(this.getNode(), error as Error, { itemIndex }); + } +} diff --git a/packages/n8n-node/nodes/Probo/actions/accessReviewSource/index.ts b/packages/n8n-node/nodes/Probo/actions/accessReviewSource/index.ts index 742d61e3c0..bfcebc1e2f 100644 --- a/packages/n8n-node/nodes/Probo/actions/accessReviewSource/index.ts +++ b/packages/n8n-node/nodes/Probo/actions/accessReviewSource/index.ts @@ -19,7 +19,9 @@ // SOFTWARE. import type { INodeProperties } from 'n8n-workflow'; +import * as createOp from './create.operation'; import * as getAllOp from './getAll.operation'; +import * as setupAwsOp from './setupAws.operation'; export const description: INodeProperties[] = [ { @@ -33,16 +35,30 @@ export const description: INodeProperties[] = [ }, }, options: [ + { + name: 'Create', + value: 'create', + description: 'Create an AWS workload-identity access source', + action: 'Create an access review source', + }, { name: 'Get Many', value: 'getAll', description: 'Get many access review sources', action: 'Get many access review sources', }, + { + name: 'Setup AWS', + value: 'setupAws', + description: 'Get AWS access source setup values', + action: 'Get AWS access source setup', + }, ], default: 'getAll', }, + ...createOp.description, ...getAllOp.description, + ...setupAwsOp.description, ]; -export { getAllOp as getAll }; +export { createOp as create, getAllOp as getAll, setupAwsOp as setupAws }; diff --git a/packages/n8n-node/nodes/Probo/actions/accessReviewSource/probe.operation.ts b/packages/n8n-node/nodes/Probo/actions/accessReviewSource/probe.operation.ts new file mode 100644 index 0000000000..32cd4510c1 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/accessReviewSource/probe.operation.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Access Review Source ID', + name: 'accessReviewSourceId', + type: 'string', + displayOptions: { + show: { + resource: ['accessReviewSource'], + operation: ['probe'], + }, + }, + default: '', + description: 'The ID of the access review source', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const accessReviewSourceId = this.getNodeParameter('accessReviewSourceId', itemIndex) as string; + + const sourceQuery = ` + query GetAccessReviewSource($id: ID!) { + node(id: $id) { + __typename + ... on AccessReviewSource { + id + connectorId + } + } + } + `; + + const sourceResponse = await proboApiRequest.call(this, sourceQuery, { + id: accessReviewSourceId, + }); + + const node = (sourceResponse.data as IDataObject | undefined)?.node as IDataObject | undefined; + if (!node || node.__typename !== 'AccessReviewSource') { + throw new NodeOperationError( + this.getNode(), + `Access review source ${accessReviewSourceId} not found`, + ); + } + + const connectorId = node.connectorId as string | undefined; + if (!connectorId) { + throw new NodeOperationError( + this.getNode(), + `Access review source ${accessReviewSourceId} has no connector`, + ); + } + + const query = ` + mutation ProbeConnector($input: ProbeConnectorInput!) { + probeConnector(input: $input) { + ok + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { + input: { connectorId }, + }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/packages/n8n-node/nodes/Probo/actions/accessReviewSource/setupAws.operation.ts b/packages/n8n-node/nodes/Probo/actions/accessReviewSource/setupAws.operation.ts new file mode 100644 index 0000000000..1cfab53183 --- /dev/null +++ b/packages/n8n-node/nodes/Probo/actions/accessReviewSource/setupAws.operation.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import type { INodeProperties, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow'; +import { proboApiRequest } from '../../GenericFunctions'; + +export const description: INodeProperties[] = [ + { + displayName: 'Organization ID', + name: 'organizationId', + type: 'string', + displayOptions: { + show: { + resource: ['accessReviewSource'], + operation: ['setupAws'], + }, + }, + default: '', + description: 'The ID of the organization', + required: true, + }, +]; + +export async function execute( + this: IExecuteFunctions, + itemIndex: number, +): Promise { + const organizationId = this.getNodeParameter('organizationId', itemIndex) as string; + + const query = ` + query AwsConnectorSetup($organizationId: ID!) { + awsConnectorSetup(organizationId: $organizationId) { + issuer + audience + subject + suggestedRoleName + terraformSnippet + cloudFormationQuickCreateURL + } + } + `; + + const responseData = await proboApiRequest.call(this, query, { organizationId }); + + return { + json: responseData, + pairedItem: { item: itemIndex }, + }; +} diff --git a/pkg/awsx/arn/arn.go b/pkg/awsx/arn/arn.go index 759a135606..c3aa4d105d 100644 --- a/pkg/awsx/arn/arn.go +++ b/pkg/awsx/arn/arn.go @@ -24,6 +24,10 @@ package arn import ( + "errors" + "regexp" + "strings" + awsarn "github.com/aws/aws-sdk-go-v2/aws/arn" ) @@ -32,7 +36,40 @@ const ( // different deployments with their own account namespaces. Partition = "aws" + // PartitionGov is AWS GovCloud (US). + PartitionGov = "aws-us-gov" + + // PartitionChina is AWS China. + PartitionChina = "aws-cn" + iamService = "iam" + + // RoleARNPattern is the IAM role ARN grammar. Partition is group 1, + // account is group 2, role name is group 3. The console field uses + // the same expression with the three supported partitions inlined. + RoleARNPattern = `arn:([^:]+):iam::([0-9]{12}):role(?:/[\w+=,.@-]+)*/([\w+=,.@-]{1,64})` +) + +var ( + // ErrNotRole is returned when the value is not an IAM role ARN. The + // error never echoes the input. + ErrNotRole = errors.New("not an IAM role ARN") + + // ErrUnsupportedPartition is returned when the role ARN names a + // partition other than commercial, GovCloud, or China. + ErrUnsupportedPartition = errors.New("AWS partition is not supported") + + roleARNPattern = regexp.MustCompile("^" + RoleARNPattern + "$") +) + +type ( + // Role is the fields an IAM role ARN must carry. IAM is global, so + // there is no region. + Role struct { + Partition string + AccountID string + Name string + } ) // Format renders an ARN the way the AWS SDK does. @@ -56,3 +93,31 @@ func IAM(partition, accountID, resource string) string { func RoleARN(partition, accountID, roleName string) string { return IAM(partition, accountID, "role/"+roleName) } + +// ParseRole reads an IAM role ARN. The error never echoes the input. +func ParseRole(raw string) (Role, error) { + matches := roleARNPattern.FindStringSubmatch(strings.TrimSpace(raw)) + if matches == nil { + return Role{}, ErrNotRole + } + + partition := matches[1] + if !supportedPartition(partition) { + return Role{}, ErrUnsupportedPartition + } + + return Role{ + Partition: partition, + AccountID: matches[2], + Name: matches[3], + }, nil +} + +func supportedPartition(partition string) bool { + switch partition { + case Partition, PartitionGov, PartitionChina: + return true + default: + return false + } +} diff --git a/pkg/awsx/arn/arn_test.go b/pkg/awsx/arn/arn_test.go index a1f6e25375..21f3826d60 100644 --- a/pkg/awsx/arn/arn_test.go +++ b/pkg/awsx/arn/arn_test.go @@ -24,6 +24,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/awsx/arn" ) @@ -81,3 +82,95 @@ func TestRoleARN(t *testing.T) { arn.RoleARN("aws-us-gov", "123456789012", "ProboAudit"), ) } + +func TestParseRole(t *testing.T) { + t.Parallel() + + t.Run("accepts commercial govcloud and china role arns", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want arn.Role + }{ + { + name: "commercial", + raw: "arn:aws:iam::123456789012:role/ProboAudit", + want: arn.Role{Partition: arn.Partition, AccountID: "123456789012", Name: "ProboAudit"}, + }, + { + name: "govcloud", + raw: "arn:aws-us-gov:iam::123456789012:role/ProboAudit", + want: arn.Role{Partition: arn.PartitionGov, AccountID: "123456789012", Name: "ProboAudit"}, + }, + { + name: "china", + raw: "arn:aws-cn:iam::123456789012:role/ProboAudit", + want: arn.Role{Partition: arn.PartitionChina, AccountID: "123456789012", Name: "ProboAudit"}, + }, + { + name: "path", + raw: "arn:aws:iam::123456789012:role/team/CustomAudit", + want: arn.Role{Partition: arn.Partition, AccountID: "123456789012", Name: "CustomAudit"}, + }, + { + name: "trims space", + raw: " arn:aws:iam::123456789012:role/ProboAudit ", + want: arn.Role{Partition: arn.Partition, AccountID: "123456789012", Name: "ProboAudit"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := arn.ParseRole(tt.raw) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } + }) + + t.Run("refuses shapes that are not an iam role", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + }{ + {name: "empty", raw: ""}, + {name: "not an arn", raw: "not-an-arn"}, + {name: "user", raw: "arn:aws:iam::123456789012:user/alice"}, + {name: "s3", raw: "arn:aws:s3:::bucket/key"}, + {name: "region set", raw: "arn:aws:iam:us-east-1:123456789012:role/ProboAudit"}, + {name: "short account", raw: "arn:aws:iam::123:role/ProboAudit"}, + {name: "empty name", raw: "arn:aws:iam::123456789012:role/"}, + {name: "invalid name", raw: "arn:aws:iam::123456789012:role/not a role"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := arn.ParseRole(tt.raw) + require.ErrorIs(t, err, arn.ErrNotRole) + + if tt.raw != "" { + assert.NotContains(t, err.Error(), tt.raw) + } + }) + } + }) + + t.Run("refuses an unsupported partition without echoing it", func(t *testing.T) { + t.Parallel() + + raw := "arn:aws-iso:iam::123456789012:role/ProboAudit" + + _, err := arn.ParseRole(raw) + require.ErrorIs(t, err, arn.ErrUnsupportedPartition) + assert.NotContains(t, err.Error(), raw) + assert.NotContains(t, err.Error(), "aws-iso") + }) +} diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index 2c4f223226..98abaa84fb 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -26,6 +26,7 @@ import ( "slices" "strings" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/connector/provider" "go.probo.inc/probo/pkg/probodconfig" @@ -198,6 +199,14 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) { Enabled: identityFederationEnabled, IssuerBaseURL: b.resolver.getEnv("PROBOD_IDENTITY_FEDERATION_ISSUER_BASE_URL"), SigningKeys: identityFederationSigningKeys, + CloudFormationTemplateURL: b.resolver.getEnvOrDefault( + "PROBOD_IDENTITY_FEDERATION_CLOUDFORMATION_TEMPLATE_URL", + cloudaws.DefaultCloudFormationTemplateURL, + ), + TerraformModuleSource: b.resolver.getEnvOrDefault( + "PROBOD_IDENTITY_FEDERATION_TERRAFORM_MODULE_SOURCE", + cloudaws.DefaultTerraformModuleSource, + ), }, ITAM: probodconfig.ITAMConfig{ DeviceEnrollmentTokenValidity: b.resolver.getEnvIntOrDefault( diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 00b11d4035..25349fe5e5 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -29,6 +29,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/crypto/keys" "go.probo.inc/probo/pkg/crypto/pem" @@ -1063,6 +1064,22 @@ func TestBuilder_Build_IdentityFederationDisabledByDefault(t *testing.T) { assert.False(t, cfg.Probod.IdentityFederation.Enabled) assert.Empty(t, cfg.Probod.IdentityFederation.IssuerBaseURL) assert.Empty(t, cfg.Probod.IdentityFederation.SigningKeys) + assert.Equal(t, cloudaws.DefaultCloudFormationTemplateURL, cfg.Probod.IdentityFederation.CloudFormationTemplateURL) + assert.Equal(t, cloudaws.DefaultTerraformModuleSource, cfg.Probod.IdentityFederation.TerraformModuleSource) +} + +func TestBuilder_Build_IdentityFederationInstallArtifactsFromEnv(t *testing.T) { + env := requiredEnv() + env["PROBOD_IDENTITY_FEDERATION_CLOUDFORMATION_TEMPLATE_URL"] = "https://example.com/audit-role.yaml" + env["PROBOD_IDENTITY_FEDERATION_TERRAFORM_MODULE_SOURCE"] = "example/terraform-aws-audit-role" + + b := NewBuilder(NewResolver(mockEnv(env))) + + cfg, err := b.Build() + require.NoError(t, err) + + assert.Equal(t, "https://example.com/audit-role.yaml", cfg.Probod.IdentityFederation.CloudFormationTemplateURL) + assert.Equal(t, "example/terraform-aws-audit-role", cfg.Probod.IdentityFederation.TerraformModuleSource) } func TestBuilder_Build_IdentityFederationDisabledSkipsSigningKey(t *testing.T) { diff --git a/pkg/cloud/aws/session.go b/pkg/cloud/aws/session.go index 3a1578de3f..6f3bb7388c 100644 --- a/pkg/cloud/aws/session.go +++ b/pkg/cloud/aws/session.go @@ -210,7 +210,8 @@ func NewSessionFromConfig(accountID, partition string, cfg awssdk.Config) *Sessi func (s *Session) CheckAccess(ctx context.Context) error { client := sts.NewFromConfig(s.cfg) - if _, err := client.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}); err != nil { + _, err := client.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { return fmt.Errorf("cannot reach aws account: %w", err) } diff --git a/pkg/cloud/aws/setup.go b/pkg/cloud/aws/setup.go new file mode 100644 index 0000000000..f3aa8b3b84 --- /dev/null +++ b/pkg/cloud/aws/setup.go @@ -0,0 +1,204 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package aws + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "go.probo.inc/probo/pkg/awsx/arn" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/identityfederation" +) + +const ( + DefaultCloudFormationTemplateURL = "https://probo-cloudformation-template.s3.us-east-2.amazonaws.com/aws-audit-role.yaml" + DefaultTerraformModuleSource = "getprobo/audit-role/aws" + + cloudFormationConsoleHost = "us-east-1.console.aws.amazon.com" + cloudFormationConsolePath = "/cloudformation/home" + cloudFormationConsoleRegion = "us-east-1" + cloudFormationStackName = "probo-audit" +) + +type ( + // ConnectorSetup is the server-derived values the connect dialog shows so + // the customer never retypes an issuer, subject or audience. + ConnectorSetup struct { + Issuer string + Audience string + Subject string + SuggestedRoleName string + TerraformSnippet string + CloudFormationQuickCreateURL string + } + + // ConnectorInstallConfig is the deployment-supplied install artifacts used + // to fill snippets and one-click links. Empty fields omit that install path. + ConnectorInstallConfig struct { + CloudFormationTemplateURL string + TerraformModuleSource string + } + + // ConnectorSetupInput is what BuildConnectorSetup needs. Every Probo-derived + // string is supplied already resolved so this function never talks to config + // or an issuer. + ConnectorSetupInput struct { + IssuerURL string + Subject string + CloudFormationTemplateURL string + TerraformModuleSource string + } +) + +// BuildConnectorSetup fills issuer, subject and snippets from already-resolved +// values. It does not mint a token and does not read global config. +func BuildConnectorSetup(in ConnectorSetupInput) (ConnectorSetup, error) { + if in.IssuerURL == "" { + return ConnectorSetup{}, fmt.Errorf("cannot build aws connector setup: issuer is required") + } + + if in.Subject == "" { + return ConnectorSetup{}, fmt.Errorf("cannot build aws connector setup: subject is required") + } + + quickCreateURL, err := cloudFormationQuickCreateURL( + in.CloudFormationTemplateURL, + in.IssuerURL, + in.Subject, + ) + if err != nil { + return ConnectorSetup{}, fmt.Errorf("cannot build aws connector setup: %w", err) + } + + return ConnectorSetup{ + Issuer: in.IssuerURL, + Audience: identityfederation.AudienceAWS, + Subject: in.Subject, + SuggestedRoleName: coredata.DefaultAWSRoleName, + TerraformSnippet: terraformSnippet(in.TerraformModuleSource, in.IssuerURL, in.Subject), + CloudFormationQuickCreateURL: quickCreateURL, + }, nil +} + +// ConnectorSetupFor resolves issuer and subject from the live issuer, then +// fills snippets. It is what GraphQL and MCP call so those surfaces cannot +// drift from token minting. +func ConnectorSetupFor( + issuer *identityfederation.Issuer, + organizationID gid.GID, + install ConnectorInstallConfig, +) (ConnectorSetup, error) { + if issuer == nil { + return ConnectorSetup{}, fmt.Errorf("cannot build aws connector setup: identity federation is not configured") + } + + issuerURL, err := issuer.IssuerURL(organizationID) + if err != nil { + return ConnectorSetup{}, fmt.Errorf("cannot build aws connector setup: %w", err) + } + + return BuildConnectorSetup( + ConnectorSetupInput{ + IssuerURL: issuerURL, + Subject: organizationID.String(), + CloudFormationTemplateURL: install.CloudFormationTemplateURL, + TerraformModuleSource: install.TerraformModuleSource, + }, + ) +} + +// NewConnectorSettings validates the customer-supplied role ARN. The ARN is +// stored as given (trimmed) so partition, account, path and role name stay +// exact. Returned errors are safe to show a client: they never echo the ARN. +func NewConnectorSettings(roleARN string) (coredata.AWSConnectorSettings, error) { + roleARN = strings.TrimSpace(roleARN) + + if _, err := arn.ParseRole(roleARN); err != nil { + if errors.Is(err, arn.ErrUnsupportedPartition) { + return coredata.AWSConnectorSettings{}, fmt.Errorf("cannot create aws connector: awsRoleArn is not a supported AWS partition") + } + + return coredata.AWSConnectorSettings{}, fmt.Errorf("cannot create aws connector: awsRoleArn is not an IAM role ARN") + } + + return coredata.AWSConnectorSettings{ + RoleARN: roleARN, + }, nil +} + +func terraformSnippet(moduleSource, issuerURL, subject string) string { + if moduleSource == "" { + return "" + } + + var b strings.Builder + b.WriteString("module \"probo_audit\" {\n") + b.WriteString(" source = ") + b.WriteString(strconv.Quote(moduleSource)) + b.WriteString("\n\n") + b.WriteString(" probo_issuer_url = ") + b.WriteString(strconv.Quote(issuerURL)) + b.WriteString("\n") + b.WriteString(" probo_subject = ") + b.WriteString(strconv.Quote(subject)) + b.WriteString("\n") + b.WriteString(" role_name = ") + b.WriteString(strconv.Quote(coredata.DefaultAWSRoleName)) + b.WriteString("\n") + b.WriteString("}\n") + + return b.String() +} + +func cloudFormationQuickCreateURL(templateURL, issuerURL, subject string) (string, error) { + if templateURL == "" { + return "", nil + } + + consoleURL := &url.URL{ + Scheme: "https", + Host: cloudFormationConsoleHost, + Path: cloudFormationConsolePath, + } + + query := url.Values{} + query.Set("region", cloudFormationConsoleRegion) + consoleURL.RawQuery = query.Encode() + + fragmentQuery := url.Values{} + fragmentQuery.Set("templateURL", templateURL) + fragmentQuery.Set("stackName", cloudFormationStackName) + fragmentQuery.Set("param_ProboIssuerURL", issuerURL) + fragmentQuery.Set("param_ProboSubject", subject) + fragmentQuery.Set("param_RoleName", coredata.DefaultAWSRoleName) + + withFragment, err := url.Parse(consoleURL.String() + "#" + "/stacks/quickcreate?" + fragmentQuery.Encode()) + if err != nil { + return "", fmt.Errorf("cannot parse cloudformation quick-create URL: %w", err) + } + + return withFragment.String(), nil +} diff --git a/pkg/cloud/aws/setup_test.go b/pkg/cloud/aws/setup_test.go new file mode 100644 index 0000000000..b682088287 --- /dev/null +++ b/pkg/cloud/aws/setup_test.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package aws_test + +import ( + "net/url" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/identityfederation" +) + +const setupOrganizationID = "e5IaD7ibAAEAAAAAAZZ9aR_Oq_Npymhg" + +func TestBuildConnectorSetup(t *testing.T) { + t.Parallel() + + issuer := "https://proboidentity.com/" + setupOrganizationID + subject := setupOrganizationID + + t.Run("fills every Probo-derived value and preserves issuer casing", func(t *testing.T) { + t.Parallel() + + setup, err := cloudaws.BuildConnectorSetup( + cloudaws.ConnectorSetupInput{ + IssuerURL: issuer, + Subject: subject, + CloudFormationTemplateURL: cloudaws.DefaultCloudFormationTemplateURL, + TerraformModuleSource: cloudaws.DefaultTerraformModuleSource, + }, + ) + require.NoError(t, err) + + assert.Equal(t, issuer, setup.Issuer) + assert.Equal(t, identityfederation.AudienceAWS, setup.Audience) + assert.Equal(t, subject, setup.Subject) + assert.Equal(t, coredata.DefaultAWSRoleName, setup.SuggestedRoleName) + assert.Contains(t, setup.TerraformSnippet, issuer) + assert.Contains(t, setup.TerraformSnippet, subject) + assert.Contains(t, setup.TerraformSnippet, "probo_issuer_url") + assert.Contains(t, setup.TerraformSnippet, "role_name") + assert.Contains(t, setup.TerraformSnippet, strconv.Quote(coredata.DefaultAWSRoleName)) + assert.Contains(t, setup.TerraformSnippet, cloudaws.DefaultTerraformModuleSource) + assert.Contains(t, setup.CloudFormationQuickCreateURL, "quickcreate") + assert.Contains(t, setup.CloudFormationQuickCreateURL, url.QueryEscape(cloudaws.DefaultCloudFormationTemplateURL)) + assert.Contains(t, setup.CloudFormationQuickCreateURL, url.QueryEscape(issuer)) + assert.Contains(t, setup.CloudFormationQuickCreateURL, url.QueryEscape(subject)) + assert.Contains(t, setup.CloudFormationQuickCreateURL, url.QueryEscape(coredata.DefaultAWSRoleName)) + }) + + t.Run("omits install artifacts when their sources are empty", func(t *testing.T) { + t.Parallel() + + setup, err := cloudaws.BuildConnectorSetup( + cloudaws.ConnectorSetupInput{ + IssuerURL: issuer, + Subject: subject, + }, + ) + require.NoError(t, err) + + assert.Empty(t, setup.TerraformSnippet) + assert.Empty(t, setup.CloudFormationQuickCreateURL) + }) + + t.Run("refuses a missing issuer", func(t *testing.T) { + t.Parallel() + + _, err := cloudaws.BuildConnectorSetup(cloudaws.ConnectorSetupInput{Subject: subject}) + require.Error(t, err) + assert.Contains(t, err.Error(), "issuer is required") + }) +} + +func TestNewConnectorSettings(t *testing.T) { + t.Parallel() + + t.Run("stores commercial govcloud and china role arns unchanged", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + roleARN string + }{ + {name: "commercial", roleARN: "arn:aws:iam::123456789012:role/ProboAudit"}, + {name: "govcloud", roleARN: "arn:aws-us-gov:iam::123456789012:role/ProboAudit"}, + {name: "china", roleARN: "arn:aws-cn:iam::123456789012:role/ProboAudit"}, + {name: "path", roleARN: "arn:aws:iam::123456789012:role/team/CustomAudit"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + settings, err := cloudaws.NewConnectorSettings(tt.roleARN) + require.NoError(t, err) + assert.Equal(t, tt.roleARN, settings.RoleARN) + }) + } + }) + + t.Run("trims space and keeps the given arn", func(t *testing.T) { + t.Parallel() + + roleARN := "arn:aws:iam::123456789012:role/ProboAudit" + + settings, err := cloudaws.NewConnectorSettings(" " + roleARN + " ") + require.NoError(t, err) + assert.Equal(t, roleARN, settings.RoleARN) + }) + + t.Run("refuses a non-role arn without echoing it", func(t *testing.T) { + t.Parallel() + + raw := "arn:aws:iam::123456789012:user/alice" + + _, err := cloudaws.NewConnectorSettings(raw) + require.Error(t, err) + assert.Contains(t, err.Error(), "awsRoleArn is not an IAM role ARN") + assert.NotContains(t, err.Error(), raw) + assert.NotContains(t, err.Error(), "alice") + }) + + t.Run("refuses an unsupported partition without echoing it", func(t *testing.T) { + t.Parallel() + + raw := "arn:aws-iso:iam::123456789012:role/ProboAudit" + + _, err := cloudaws.NewConnectorSettings(raw) + require.Error(t, err) + assert.Contains(t, err.Error(), "supported AWS partition") + assert.NotContains(t, err.Error(), raw) + assert.NotContains(t, err.Error(), "aws-iso") + }) +} diff --git a/pkg/cmd/access-review/source/create/create.go b/pkg/cmd/access-review/source/create/create.go index b416fea633..e48d555d14 100644 --- a/pkg/cmd/access-review/source/create/create.go +++ b/pkg/cmd/access-review/source/create/create.go @@ -22,6 +22,7 @@ package create import ( "encoding/json" + "errors" "fmt" "os" @@ -30,7 +31,10 @@ import ( "go.probo.inc/probo/pkg/cmd/cmdutil" ) -const createMutation = ` +const connectionStatusConnected = "CONNECTED" + +const ( + createMutation = ` mutation($input: CreateAccessReviewSourceInput!) { createAccessReviewSource(input: $input) { created @@ -44,17 +48,48 @@ mutation($input: CreateAccessReviewSourceInput!) { } ` -type createResponse struct { - CreateAccessReviewSource struct { - Created bool `json:"created"` - AccessReviewSourceEdge struct { - Node struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"node"` - } `json:"accessReviewSourceEdge"` - } `json:"createAccessReviewSource"` + createAWSConnectorMutation = ` +mutation($input: CreateWorkloadIdentityConnectorInput!) { + createWorkloadIdentityConnector(input: $input) { + connector { + id + connectionStatus + } + } +} +` + + deleteConnectorMutation = ` +mutation($input: DeleteConnectorInput!) { + deleteConnector(input: $input) { + deletedConnectorId + } } +` +) + +type ( + createResponse struct { + CreateAccessReviewSource struct { + Created bool `json:"created"` + AccessReviewSourceEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"node"` + } `json:"accessReviewSourceEdge"` + } `json:"createAccessReviewSource"` + } + + createAWSConnectorResponse struct { + CreateWorkloadIdentityConnector struct { + Connector struct { + ID string `json:"id"` + ConnectionStatus string `json:"connectionStatus"` + } `json:"connector"` + } `json:"createWorkloadIdentityConnector"` + } +) func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { var ( @@ -62,6 +97,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { flagName string flagCSVFile string flagConnectorID string + flagRoleARN string ) cmd := &cobra.Command{ @@ -71,7 +107,10 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { prb access-review source create --name "Okta Users" --csv-file users.csv # Create an access source with a connector - prb access-review source create --name "GitHub" --connector-id `, + prb access-review source create --name "GitHub" --connector-id + + # Create an AWS workload-identity access source + prb access-review source create --name "AWS prod" --aws-role-arn arn:aws:iam::123456789012:role/ProboAudit`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := f.Config() @@ -100,8 +139,24 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("cannot determine organization, use --org or 'prb auth login'") } - if flagCSVFile != "" && flagConnectorID != "" { - return fmt.Errorf("cannot specify both --csv-file and --connector-id") + var createdConnectorID string + + if flagRoleARN != "" { + connectorID, status, err := createAWSConnector(client, flagOrg, flagRoleARN) + if err != nil { + return err + } + + createdConnectorID = connectorID + flagConnectorID = connectorID + + if status != connectionStatusConnected { + return abandonCreatedConnector( + client, + createdConnectorID, + fmt.Errorf("connector is %s", status), + ) + } } input := map[string]any{ @@ -127,12 +182,16 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { map[string]any{"input": input}, ) if err != nil { - return err + return abandonCreatedConnector(client, createdConnectorID, err) } var resp createResponse if err := json.Unmarshal(data, &resp); err != nil { - return fmt.Errorf("cannot parse response: %w", err) + return abandonCreatedConnector( + client, + createdConnectorID, + fmt.Errorf("cannot parse response: %w", err), + ) } s := resp.CreateAccessReviewSource.AccessReviewSourceEdge.Node @@ -154,8 +213,59 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&flagName, "name", "", "Access source name (required)") cmd.Flags().StringVar(&flagCSVFile, "csv-file", "", "Path to CSV file with access data") cmd.Flags().StringVar(&flagConnectorID, "connector-id", "", "Connector ID to use as data source") + cmd.Flags().StringVar(&flagRoleARN, "aws-role-arn", "", "IAM role ARN") _ = cmd.MarkFlagRequired("name") + cmd.MarkFlagsMutuallyExclusive("csv-file", "connector-id", "aws-role-arn") return cmd } + +func createAWSConnector( + client *api.Client, + orgID string, + roleARN string, +) (string, string, error) { + input := map[string]any{ + "organizationId": orgID, + "provider": "AWS", + "awsRoleArn": roleARN, + } + + data, err := client.Do(createAWSConnectorMutation, map[string]any{"input": input}) + if err != nil { + return "", "", err + } + + var resp createAWSConnectorResponse + if err := json.Unmarshal(data, &resp); err != nil { + return "", "", fmt.Errorf("cannot parse response: %w", err) + } + + cnnctr := resp.CreateWorkloadIdentityConnector.Connector + + return cnnctr.ID, cnnctr.ConnectionStatus, nil +} + +func abandonCreatedConnector(client *api.Client, connectorID string, cause error) error { + if connectorID == "" { + return cause + } + + _, err := client.Do( + deleteConnectorMutation, + map[string]any{ + "input": map[string]any{ + "connectorId": connectorID, + }, + }, + ) + if err != nil { + return errors.Join( + cause, + fmt.Errorf("cannot delete leftover connector %s: %w", connectorID, err), + ) + } + + return cause +} diff --git a/pkg/cmd/access-review/source/probe/probe.go b/pkg/cmd/access-review/source/probe/probe.go new file mode 100644 index 0000000000..8edca7d185 --- /dev/null +++ b/pkg/cmd/access-review/source/probe/probe.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package probe + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const ( + sourceQuery = ` +query($id: ID!) { + node(id: $id) { + __typename + ... on AccessReviewSource { + id + connectorId + } + } +} +` + + probeMutation = ` +mutation($input: ProbeConnectorInput!) { + probeConnector(input: $input) { + ok + } +} +` +) + +type ( + sourceResponse struct { + Node *struct { + Typename string `json:"__typename"` + ID string `json:"id"` + ConnectorID *string `json:"connectorId"` + } `json:"node"` + } + + probeResponse struct { + ProbeConnector struct { + Ok bool `json:"ok"` + } `json:"probeConnector"` + } +) + +func NewCmdProbe(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "probe ", + Short: "Probe an access source connector", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + sourceID := args[0] + + data, err := client.Do(sourceQuery, map[string]any{"id": sourceID}) + if err != nil { + return err + } + + var sourceResp sourceResponse + if err := json.Unmarshal(data, &sourceResp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + if sourceResp.Node == nil { + return fmt.Errorf("access source %s not found", sourceID) + } + + if sourceResp.Node.Typename != "AccessReviewSource" { + return fmt.Errorf("expected AccessReviewSource node, got %s", sourceResp.Node.Typename) + } + + if sourceResp.Node.ConnectorID == nil || *sourceResp.Node.ConnectorID == "" { + return fmt.Errorf("access source %s has no connector", sourceID) + } + + data, err = client.Do( + probeMutation, + map[string]any{"input": map[string]any{"connectorId": *sourceResp.Node.ConnectorID}}, + ) + if err != nil { + return err + } + + var resp probeResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse probe response: %w", err) + } + + return cmdutil.PrintJSON(f.IOStreams.Out, resp.ProbeConnector) + }, + } + + return cmd +} diff --git a/pkg/cmd/access-review/source/setup-aws/setup_aws.go b/pkg/cmd/access-review/source/setup-aws/setup_aws.go new file mode 100644 index 0000000000..4a0edcedfb --- /dev/null +++ b/pkg/cmd/access-review/source/setup-aws/setup_aws.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package setupaws + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "go.probo.inc/probo/pkg/cli/api" + "go.probo.inc/probo/pkg/cmd/cmdutil" +) + +const setupQuery = ` +query($organizationId: ID!) { + awsConnectorSetup(organizationId: $organizationId) { + issuer + audience + subject + suggestedRoleName + terraformSnippet + cloudFormationQuickCreateURL + } +} +` + +type setupResponse struct { + AWSConnectorSetup struct { + Issuer string `json:"issuer"` + Audience string `json:"audience"` + Subject string `json:"subject"` + SuggestedRoleName string `json:"suggestedRoleName"` + TerraformSnippet string `json:"terraformSnippet"` + CloudFormationQuickCreateURL string `json:"cloudFormationQuickCreateURL"` + } `json:"awsConnectorSetup"` +} + +func NewCmdSetupAWS(f *cmdutil.Factory) *cobra.Command { + var flagOrg string + + cmd := &cobra.Command{ + Use: "setup-aws", + Short: "Show AWS access source setup values", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + host, hc, err := cfg.DefaultHost() + if err != nil { + return err + } + + client := api.NewClient( + host, + hc.Token, + "/api/console/v1/graphql", + cfg.HTTPTimeoutDuration(), + cmdutil.TokenRefreshOption(cfg, host, hc), + ) + + if flagOrg == "" { + flagOrg = hc.Organization + } + + if flagOrg == "" { + return fmt.Errorf("organization is required; pass --org or set a default with 'prb auth login'") + } + + data, err := client.Do(setupQuery, map[string]any{"organizationId": flagOrg}) + if err != nil { + return err + } + + var resp setupResponse + if err := json.Unmarshal(data, &resp); err != nil { + return fmt.Errorf("cannot parse response: %w", err) + } + + return cmdutil.PrintJSON(f.IOStreams.Out, resp.AWSConnectorSetup) + }, + } + + cmd.Flags().StringVar(&flagOrg, "org", "", "Organization ID") + + return cmd +} diff --git a/pkg/cmd/access-review/source/source.go b/pkg/cmd/access-review/source/source.go index 6c399edc20..1b5bad67bb 100644 --- a/pkg/cmd/access-review/source/source.go +++ b/pkg/cmd/access-review/source/source.go @@ -25,6 +25,7 @@ import ( "go.probo.inc/probo/pkg/cmd/access-review/source/create" "go.probo.inc/probo/pkg/cmd/access-review/source/delete" "go.probo.inc/probo/pkg/cmd/access-review/source/list" + setupaws "go.probo.inc/probo/pkg/cmd/access-review/source/setup-aws" "go.probo.inc/probo/pkg/cmd/access-review/source/update" "go.probo.inc/probo/pkg/cmd/access-review/source/view" "go.probo.inc/probo/pkg/cmd/cmdutil" @@ -41,6 +42,7 @@ func NewCmdSource(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(view.NewCmdView(f)) cmd.AddCommand(update.NewCmdUpdate(f)) cmd.AddCommand(delete.NewCmdDelete(f)) + cmd.AddCommand(setupaws.NewCmdSetupAWS(f)) return cmd } diff --git a/pkg/cmd/access-review/source/view/view.go b/pkg/cmd/access-review/source/view/view.go index 3bb48c43c1..6cde2515e0 100644 --- a/pkg/cmd/access-review/source/view/view.go +++ b/pkg/cmd/access-review/source/view/view.go @@ -38,6 +38,7 @@ query($id: ID!) { id name connectorId + connectionStatus createdAt updatedAt } @@ -47,12 +48,13 @@ query($id: ID!) { type viewResponse struct { Node *struct { - Typename string `json:"__typename"` - ID string `json:"id"` - Name string `json:"name"` - ConnectorID *string `json:"connectorId"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Typename string `json:"__typename"` + ID string `json:"id"` + Name string `json:"name"` + ConnectorID *string `json:"connectorId"` + ConnectionStatus string `json:"connectionStatus"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } `json:"node"` } @@ -126,6 +128,8 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command { _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Connector:"), *s.ConnectorID) } + _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Connection:"), s.ConnectionStatus) + _, _ = fmt.Fprintln(out) _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(s.CreatedAt)) _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(s.UpdatedAt)) diff --git a/pkg/connector/provider/aws.go b/pkg/connector/provider/aws.go index 6609047cc0..e42377790e 100644 --- a/pkg/connector/provider/aws.go +++ b/pkg/connector/provider/aws.go @@ -11,7 +11,7 @@ // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING WITHOUT LIMITATION THE WARRANTIES OF MERCHANTABILITY, +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, @@ -36,18 +36,23 @@ import ( // AWS credential and mints an assertion the customer's STS exchanges for // temporary ones. It therefore declares no OAuth2, API-key or // client-credentials path — there is no credential for a customer to paste or -// an operator to configure. It also registers no InspectGrant: each -// organization has its own issuer, so STS rejects a foreign token before any -// trust-policy condition runs. +// an operator to configure. +// +// Isolation is the per-organization issuer; a successful assume is the whole +// check, so there is no grant readback beside Probe. func awsRegistration() *Registration { return &Registration{ - Provider: coredata.ConnectorProviderAWS, - DisplayName: "Amazon Web Services", + Provider: coredata.ConnectorProviderAWS, + DisplayName: "Amazon Web Services", + // See Registration.EndpointOverrideUnsupported: the AWS SDK resolves every host it dials from the session's region and partition, so there is no host in Endpoints for an override to move. EndpointOverrideUnsupported: "the AWS SDK resolves its own endpoints from the session region, not from values in Endpoints", WorkloadIdentity: &WorkloadIdentityConfig{ NewSession: newAWSSession, NewDriver: newAWSDriver, Probe: probeAWS, + ExtraSettings: []ExtraSetting{ + {Key: "roleArn", Label: "Role ARN", Required: true}, + }, }, } } diff --git a/pkg/connector/provider/aws_test.go b/pkg/connector/provider/aws_test.go index 625e5791f4..e18b0f3950 100644 --- a/pkg/connector/provider/aws_test.go +++ b/pkg/connector/provider/aws_test.go @@ -103,9 +103,9 @@ func TestAWSRegistration(t *testing.T) { assert.NotEmpty(t, reg.EndpointOverrideUnsupported) assert.Equal(t, provider.Endpoints{}, reg.Endpoints) - // Isolation is the per-organization issuer, so there is no grant to read - // back beyond a successful assume. - assert.Nil(t, reg.WorkloadIdentity.InspectGrant) + require.Len(t, reg.WorkloadIdentityExtraSettings(), 1) + assert.Equal(t, "roleArn", reg.WorkloadIdentityExtraSettings()[0].Key) + assert.True(t, reg.WorkloadIdentityExtraSettings()[0].Required) } func TestAWSNewSession(t *testing.T) { diff --git a/pkg/connector/provider/registry.go b/pkg/connector/provider/registry.go index 3cd2d73b68..e622158f8d 100644 --- a/pkg/connector/provider/registry.go +++ b/pkg/connector/provider/registry.go @@ -217,15 +217,16 @@ func (r *Registry) Register(reg *Registration) error { // The console keys both its form state and its submitted values by setting // key within one dialog, so a duplicate key silently collapses two fields // into one and an empty key produces an unlabelled field bound to nothing. - // Reject both at startup. A key repeated across the two lists is fine and - // intended: that is how a dual-path provider declares one setting both - // dialogs need. + // Reject both at startup. A key repeated across lists is fine and + // intended: that is how a multi-path provider declares one setting + // each dialog that needs it. for _, list := range []struct { field string settings []ExtraSetting }{ {"APIKey.ExtraSettings", reg.APIKeyExtraSettings()}, {"ClientCredentials.ExtraSettings", reg.ClientCredentialsExtraSettings()}, + {"WorkloadIdentity.ExtraSettings", reg.WorkloadIdentityExtraSettings()}, } { seen := make(map[string]bool, len(list.settings)) diff --git a/pkg/connector/provider/registry_test.go b/pkg/connector/provider/registry_test.go index 21c92efe4c..9a83249163 100644 --- a/pkg/connector/provider/registry_test.go +++ b/pkg/connector/provider/registry_test.go @@ -141,6 +141,15 @@ func TestEveryProviderSettingsReachADialog(t *testing.T) { reg.Provider, ) } + + if len(reg.WorkloadIdentityExtraSettings()) > 0 { + assert.Truef( + t, + reg.SupportsWorkloadIdentity(), + "provider %q declares WorkloadIdentityExtraSettings but offers no workload-identity path", + reg.Provider, + ) + } }) } } @@ -292,9 +301,9 @@ func TestRegistry_Register(t *testing.T) { assert.Contains(t, err.Error(), `APIKey.ExtraSettings declares duplicate setting key "region"`) }) - // One setting both dialogs need is declared in both lists; that is not a - // duplicate, because each list keys a separate form. - t.Run("setting key repeated across the two lists", func(t *testing.T) { + // One setting more than one dialog needs is declared in each of those + // lists; that is not a duplicate, because each list keys a separate form. + t.Run("setting key repeated across lists", func(t *testing.T) { t.Parallel() r := provider.NewRegistry() @@ -326,6 +335,25 @@ func TestRegistry_Register(t *testing.T) { assert.Contains(t, err.Error(), "APIKey.ExtraSettings declares a setting with an empty Key or Label") }) + t.Run("setting with an empty Key on WorkloadIdentity", func(t *testing.T) { + t.Parallel() + + r := provider.NewRegistry() + err := r.Register(&provider.Registration{ + Provider: coredata.ConnectorProviderSlack, + DisplayName: "Slack", + WorkloadIdentity: &provider.WorkloadIdentityConfig{ + NewSession: stubNewCloudSession, + NewDriver: stubNewCloudDriver, + ExtraSettings: []provider.ExtraSetting{ + {Label: "Account ID"}, + }, + }, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "WorkloadIdentity.ExtraSettings declares a setting with an empty Key or Label") + }) + t.Run("setting with an empty Label", func(t *testing.T) { t.Parallel() diff --git a/pkg/connector/provider/types.go b/pkg/connector/provider/types.go index 09068ad37d..a9575b2296 100644 --- a/pkg/connector/provider/types.go +++ b/pkg/connector/provider/types.go @@ -371,18 +371,11 @@ type WorkloadIdentityConfig struct { // path. Nil means the check is skipped, matching an empty Endpoints.Probe. Probe func(context.Context, cloud.Session, *coredata.Connector) error - // InspectGrant reads the grant the customer deployed in their own cloud - // account and refuses it when it does not match what this provider - // requires. It runs once, before activation. - // - // It is separate from Probe. Probe asks whether the assume still works, on - // every status check. This asks whether the grant itself is acceptable, - // which cannot change without the customer editing their infrastructure. - // - // Nil means a successful assume is enough: isolation is already - // structural (a per-organization issuer), or there is no grant document - // to read. - InspectGrant func(context.Context, cloud.Session, *coredata.Connector) error + // ExtraSettings declares the per-provider settings fields the console's + // workload-identity connect dialog renders and submits, in render order. + // Empty when the provider needs none beyond the grant in the customer's + // own cloud account. + ExtraSettings []ExtraSetting } // The three Supports* predicates below are derived from the presence of a @@ -436,6 +429,16 @@ func (r *Registration) ClientCredentialsExtraSettings() []ExtraSetting { return r.ClientCredentials.ExtraSettings } +// WorkloadIdentityExtraSettings returns the workload-identity dialog's +// settings fields, or nil when the provider has no such path. +func (r *Registration) WorkloadIdentityExtraSettings() []ExtraSetting { + if r.WorkloadIdentity == nil { + return nil + } + + return r.WorkloadIdentity.ExtraSettings +} + // ExtraSetting describes one extra per-provider settings field // surfaced on ConnectorProviderInfo for the frontend to render. type ExtraSetting struct { diff --git a/pkg/coredata/connector_settings.go b/pkg/coredata/connector_settings.go index f5f38e39a6..6b32669ce0 100644 --- a/pkg/coredata/connector_settings.go +++ b/pkg/coredata/connector_settings.go @@ -244,16 +244,6 @@ type ( // RoleARN is the IAM role the customer created for Probo. The account // is the one that ARN names; it is not stored separately. RoleARN string `json:"role_arn"` - - // Issuer is the issuer URL this connector's tokens were registered - // under, recorded when the customer created it. - // - // It exists so that changing the deployment's issuer is not a - // fleet-wide flag day: every customer's OIDC provider and trust - // policy pin the URL they registered, and they can only redeploy on - // their own schedule. Recording it per connector is what lets old and - // new coexist while they do. - Issuer string `json:"issuer"` } ) diff --git a/pkg/coredata/connector_settings_test.go b/pkg/coredata/connector_settings_test.go index 5b21ad30fe..164beca694 100644 --- a/pkg/coredata/connector_settings_test.go +++ b/pkg/coredata/connector_settings_test.go @@ -78,7 +78,6 @@ func TestConnectorSettings_RoundTrip(t *testing.T) { want := coredata.AWSConnectorSettings{ RoleARN: "arn:aws:iam::123456789012:role/" + coredata.DefaultAWSRoleName, - Issuer: "https://proboidentity.com/e5IaD7ibAAEAAAAAAZZ9aR_Oq_Npymhg", } c := &coredata.Connector{} require.NoError(t, c.SetSettings(&want)) diff --git a/pkg/identityfederation/issuer_test.go b/pkg/identityfederation/issuer_test.go index 9f8b48f084..b0f7ab5a0d 100644 --- a/pkg/identityfederation/issuer_test.go +++ b/pkg/identityfederation/issuer_test.go @@ -154,14 +154,6 @@ func decodeTokenHeader(t testing.TB, token string) jose.JWTHeader { return header } -func TestSubject(t *testing.T) { - t.Parallel() - - organizationID := fixtureOrganizationGID(t) - - assert.Equal(t, fixtureOrganizationID, organizationID.String()) -} - // Key material is validated by jose.NewKeyRing, which has its own tests; what // remains here is what the issuer itself requires. func TestNewIssuer_Errors(t *testing.T) { diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index d9407700c6..e2d4a7caff 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -54,6 +54,7 @@ import ( "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/bot" "go.probo.inc/probo/pkg/certmanager" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" portal "go.probo.inc/probo/pkg/complianceportal" "go.probo.inc/probo/pkg/complianceportal/management" "go.probo.inc/probo/pkg/complianceportal/visitor" @@ -156,6 +157,10 @@ func New() *Implm { DomainVerificationResolverAddr: "8.8.8.8:53", }, }, + IdentityFederation: IdentityFederationConfig{ + CloudFormationTemplateURL: cloudaws.DefaultCloudFormationTemplateURL, + TerraformModuleSource: cloudaws.DefaultTerraformModuleSource, + }, ITAM: ITAMConfig{ DeviceEnrollmentTokenValidity: 604800, }, @@ -963,6 +968,10 @@ func (impl *Implm) Run( Logger: l.Named("http.server"), Cookie: authCookie, IdentityFederationIssuer: identityFederationIssuer, + AWSConnectorInstall: cloudaws.ConnectorInstallConfig{ + CloudFormationTemplateURL: impl.cfg.IdentityFederation.CloudFormationTemplateURL, + TerraformModuleSource: impl.cfg.IdentityFederation.TerraformModuleSource, + }, }, ) if err != nil { diff --git a/pkg/probodconfig/identity_federation_config.go b/pkg/probodconfig/identity_federation_config.go index 43b77cbb16..190d21792f 100644 --- a/pkg/probodconfig/identity_federation_config.go +++ b/pkg/probodconfig/identity_federation_config.go @@ -34,6 +34,12 @@ type ( // infrastructure they own. IssuerBaseURL string `json:"issuer-base-url,omitempty"` SigningKeys []IdentityFederationSigningKeyConfig `json:"signing-keys,omitempty"` + // CloudFormationTemplateURL is the public S3 HTTPS URL used to build the + // AWS console quick-create link for the audit role. + CloudFormationTemplateURL string `json:"cloudformation-template-url,omitempty"` + // TerraformModuleSource is the module address copied into the Terraform + // install snippet. + TerraformModuleSource string `json:"terraform-module-source,omitempty"` } // IdentityFederationSigningKeyConfig is one RSA key published in the identity federation diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 7bb5c4172c..c41c57e7b1 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -34,6 +34,7 @@ import ( "go.probo.inc/probo/pkg/agentexecution" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/certmanager" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/complianceportal/management" "go.probo.inc/probo/pkg/complianceportal/visitor" "go.probo.inc/probo/pkg/connector" @@ -43,6 +44,7 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/geoloc" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/identityfederation" "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" @@ -103,6 +105,10 @@ type ( CustomDomainCname string GraphQLLimits gqlutils.Limits Logger *log.Logger + + // IdentityFederationIssuer is nil when identity federation is disabled. + IdentityFederationIssuer *identityfederation.Issuer + AWSConnectorInstall cloudaws.ConnectorInstallConfig } MCPConfig struct { @@ -264,6 +270,8 @@ func NewServer(cfg Config) (*Server, error) { cfg.ComplianceMessages, cfg.GraphQLLimits, cfg.ITAM, + cfg.IdentityFederationIssuer, + cfg.AWSConnectorInstall, ), cookieBannerHandler: cookiebanner_v1.NewMux( cfg.Logger.Named("cookiebanner.v1"), @@ -295,6 +303,8 @@ func NewServer(cfg Config) (*Server, error) { cfg.TokenSecret, cfg.File, cfg.BaseURL, + cfg.IdentityFederationIssuer, + cfg.AWSConnectorInstall, ), slackHandler: slack_v1.NewMux( cfg.Logger.Named("slack.v1"), diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go index 13b6e25159..81dd3e88f2 100644 --- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go +++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go @@ -556,10 +556,9 @@ func (r *accessReviewSourceResolver) NeedsConfiguration(ctx context.Context, obj // ConnectionStatus is the resolver for the connectionStatus field. // -// Returns RECONNECT_REQUIRED when the connector's stored OAuth grant is -// missing scopes required by the current provider registration (e.g. a newly -// added Graph permission), DISCONNECTED when the credential probe fails, and -// CONNECTED when the grant is usable as-is. +// The state belongs to the connector, so this is Connector.connectionStatus +// plus the one case a connector cannot express: a manual CSV source, which has +// no connector to be connected to. func (r *accessReviewSourceResolver) ConnectionStatus(ctx context.Context, obj *types.AccessReviewSource) (types.AccessReviewSourceConnectionStatus, error) { if obj.ConnectorID == nil { return types.AccessReviewSourceConnectionStatusNotApplicable, nil @@ -570,63 +569,23 @@ func (r *accessReviewSourceResolver) ConnectionStatus(ctx context.Context, obj * return types.AccessReviewSourceConnectionStatusNotApplicable, err } - // Obtaining a credential may succeed even when it is expired or invalid - // (e.g. no refresh token available, or a dead API key). When the provider - // registers a probe, make a lightweight request to verify the credential is - // actually accepted. - if err := r.accessReview.ProbeConnector(ctx, scope, *obj.ConnectorID); err != nil { - if errors.Is(err, coredata.ErrResourceNotFound) { - return types.AccessReviewSourceConnectionStatusNotApplicable, nil - } - - // A code, not the error: probe errors wrap provider-controlled text - // and customer-chosen hosts, which logging.md keeps out of the logs. - fields := []log.Attr{ - log.String("source_id", obj.ID.String()), - log.String("connector_id", obj.ConnectorID.String()), - } - - if probeErr, ok := errors.AsType[*accessreview.ProbeError](err); ok { - // Not Probo's failure, so not in the error budget. - r.logger.WarnCtx( - ctx, - "connector credential probe failed, reporting source disconnected", - append(fields, - log.String("provider", probeErr.Provider.String()), - log.String("probe_failure", accessreview.ProbeFailureCode(err)), - )..., - ) - - return types.AccessReviewSourceConnectionStatusDisconnected, nil - } - - // A database read, a decrypt, a deployment without identity - // federation: ours, so it stays an error and keeps its message. - r.logger.ErrorCtx( - ctx, - "cannot probe connector, reporting source disconnected", - append(fields, log.Error(err))..., - ) - - return types.AccessReviewSourceConnectionStatusDisconnected, nil - } - - needsReconnect, err := r.accessReview.SourceNeedsReconnect(ctx, scope, *obj.ConnectorID) + status, err := r.connectorConnectionStatus(ctx, scope, *obj.ConnectorID) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { return types.AccessReviewSourceConnectionStatusNotApplicable, nil } - r.logger.ErrorCtx(ctx, "cannot determine access source reconnect requirement", log.Error(err)) - - return types.AccessReviewSourceConnectionStatusNotApplicable, gqlutils.Internal(ctx) + return types.AccessReviewSourceConnectionStatusNotApplicable, err } - if needsReconnect { + switch status { + case types.ConnectorConnectionStatusConnected: + return types.AccessReviewSourceConnectionStatusConnected, nil + case types.ConnectorConnectionStatusReconnectRequired: return types.AccessReviewSourceConnectionStatusReconnectRequired, nil + default: + return types.AccessReviewSourceConnectionStatusDisconnected, nil } - - return types.AccessReviewSourceConnectionStatusConnected, nil } // SelectedOrganization is the resolver for the selectedOrganization field. diff --git a/pkg/server/api/console/v1/base_resolvers.go b/pkg/server/api/console/v1/base_resolvers.go index aaa430c1fc..74f50c27c3 100644 --- a/pkg/server/api/console/v1/base_resolvers.go +++ b/pkg/server/api/console/v1/base_resolvers.go @@ -14,6 +14,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/agentexecution" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/complianceportal/management" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" @@ -681,17 +682,15 @@ func (r *queryResolver) AccessReviewDrivers(ctx context.Context) ([]*types.Conne // and failing at connect time. apiKeyManaged := r.providerRegistry.ManagedConnectorReady(provider) - // Skip providers that cannot be connected in this deployment: no - // connector protocol configured and no key-based fallback (API key, - // managed API key, or client credentials) supported. A workload - // identity provider needs no operator configuration at all — the - // customer grants access in their own cloud account — so it is - // connectable everywhere and never skipped here. + // WIF is a connect path only when this deployment can mint federation + // tokens. AWS has no other path, so it stays hidden until then. + workloadIdentityReady := reg.SupportsWorkloadIdentity() && r.identityFederation != nil + if len(configuredProtocols) == 0 && !apiKeySupported && !clientCredentialsSupported && !apiKeyManaged && - !reg.SupportsWorkloadIdentity() { + !workloadIdentityReady { continue } @@ -721,6 +720,8 @@ func (r *queryResolver) AccessReviewDrivers(ctx context.Context) ([]*types.Conne Oauth2Scopes: scopes, APIKeyExtraSettings: connectorProviderSettingInfos(reg.APIKeyExtraSettings()), ClientCredentialsExtraSettings: connectorProviderSettingInfos(reg.ClientCredentialsExtraSettings()), + WorkloadIdentitySupported: workloadIdentityReady, + WorkloadIdentityExtraSettings: connectorProviderSettingInfos(reg.WorkloadIdentityExtraSettings()), }) } @@ -734,6 +735,30 @@ func (r *queryResolver) AccessReviewDrivers(ctx context.Context) ([]*types.Conne return infos, nil } +// AWSConnectorSetup is the resolver for the awsConnectorSetup field. +func (r *queryResolver) AWSConnectorSetup(ctx context.Context, organizationID gid.GID) (*types.AWSConnectorSetup, error) { + if _, err := r.authorize(ctx, organizationID, probo.ActionConnectorCreate); err != nil { + return nil, err + } + + if r.identityFederation == nil { + return nil, gqlutils.Invalidf(ctx, "identity federation is not configured in this deployment") + } + + setup, err := cloudaws.ConnectorSetupFor( + r.identityFederation, + organizationID, + r.awsConnectorInstall, + ) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot build aws connector setup", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return newAWSConnectorSetup(setup), nil +} + // CrispVerificationCode is the resolver for the crispVerificationCode field. It // returns the deterministic ownership-verification code the customer must paste // into the Probo plugin's per-website settings in their Crisp dashboard before diff --git a/pkg/server/api/console/v1/connector_connection_status.go b/pkg/server/api/console/v1/connector_connection_status.go new file mode 100644 index 0000000000..eb0b54ae8d --- /dev/null +++ b/pkg/server/api/console/v1/connector_connection_status.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package console_v1 + +import ( + "context" + "errors" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/server/api/console/v1/types" + "go.probo.inc/probo/pkg/server/gqlutils" +) + +// connectorConnectionStatus reports whether the connector's credential is +// usable right now, by probing the provider and comparing the granted OAuth +// scopes against the current registration. +// +// Obtaining a credential may succeed even when it is expired or invalid (no +// refresh token available, a dead API key, a role Probo can no longer assume), +// so the probe is what makes the answer trustworthy. coredata.ErrResourceNotFound +// is propagated; every caller decides for itself what a missing connector means. +func (r *Resolver) connectorConnectionStatus( + ctx context.Context, + scope coredata.Scoper, + connectorID gid.GID, +) (types.ConnectorConnectionStatus, error) { + if err := r.accessReview.ProbeConnector(ctx, scope, connectorID); err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return "", err + } + + // A code, not the error: probe errors wrap provider-controlled text + // and customer-chosen hosts, which logging.md keeps out of the logs. + field := log.String("connector_id", connectorID.String()) + + if probeErr, ok := errors.AsType[*accessreview.ProbeError](err); ok && probeErr != nil { + // Not Probo's failure, so not in the error budget. + r.logger.WarnCtx( + ctx, + "connector credential probe failed, reporting disconnected", + field, + log.String("provider", probeErr.Provider.String()), + log.String("probe_failure", accessreview.ProbeFailureCode(err)), + ) + + return types.ConnectorConnectionStatusDisconnected, nil + } + + // A database read, a decrypt, a deployment without identity + // federation: ours, so it keeps its message in the logs. + r.logger.ErrorCtx( + ctx, + "cannot probe connector, reporting disconnected", + field, + log.Error(err), + ) + + return types.ConnectorConnectionStatusDisconnected, nil + } + + needsReconnect, err := r.accessReview.SourceNeedsReconnect(ctx, scope, connectorID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return "", err + } + + r.logger.ErrorCtx(ctx, "cannot determine connector reconnect requirement", log.Error(err)) + + return "", gqlutils.Internal(ctx) + } + + if needsReconnect { + return types.ConnectorConnectionStatusReconnectRequired, nil + } + + return types.ConnectorConnectionStatusConnected, nil +} diff --git a/pkg/server/api/console/v1/connector_resolvers.go b/pkg/server/api/console/v1/connector_resolvers.go index 6d88da6bb2..e7934abe17 100644 --- a/pkg/server/api/console/v1/connector_resolvers.go +++ b/pkg/server/api/console/v1/connector_resolvers.go @@ -7,10 +7,12 @@ package console_v1 import ( "context" + "encoding/json" "errors" "fmt" "go.gearno.de/kit/log" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/probo" @@ -46,6 +48,25 @@ func (r *connectorResolver) Oauth2Scopes(ctx context.Context, obj *types.Connect return scopes, nil } +// ConnectionStatus is the resolver for the connectionStatus field. +func (r *connectorResolver) ConnectionStatus(ctx context.Context, obj *types.Connector) (types.ConnectorConnectionStatus, error) { + scope, err := r.authorize(ctx, obj.ID, probo.ActionConnectorGet) + if err != nil { + return "", err + } + + status, err := r.connectorConnectionStatus(ctx, scope, obj.ID) + if err != nil { + if errors.Is(err, coredata.ErrResourceNotFound) { + return "", gqlutils.NotFound(ctx, err) + } + + return "", err + } + + return status, nil +} + // CreateAPIKeyConnector is the resolver for the createAPIKeyConnector field. func (r *mutationResolver) CreateAPIKeyConnector(ctx context.Context, input types.CreateAPIKeyConnectorInput) (*types.CreateAPIKeyConnectorPayload, error) { scope, err := r.authorize(ctx, input.OrganizationID, probo.ActionConnectorCreate) @@ -149,6 +170,51 @@ func (r *mutationResolver) CreateClientCredentialsConnector(ctx context.Context, }, nil } +// CreateWorkloadIdentityConnector is the resolver for the createWorkloadIdentityConnector field. +func (r *mutationResolver) CreateWorkloadIdentityConnector(ctx context.Context, input types.CreateWorkloadIdentityConnectorInput) (*types.CreateWorkloadIdentityConnectorPayload, error) { + scope, err := r.authorize(ctx, input.OrganizationID, probo.ActionConnectorCreate) + if err != nil { + return nil, err + } + + if r.identityFederation == nil { + return nil, gqlutils.Invalidf(ctx, "identity federation is not configured in this deployment") + } + + if input.Provider != coredata.ConnectorProviderAWS { + return nil, gqlutils.Invalidf(ctx, "provider does not support workload identity") + } + + settings, err := cloudaws.NewConnectorSettings(input.AWSRoleArn) + if err != nil { + return nil, gqlutils.Invalid(ctx, err) + } + + raw, err := json.Marshal(settings) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot marshal aws connector settings", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + cnnctr, err := r.probo.Connectors.Create(ctx, scope, probo.CreateConnectorRequest{ + OrganizationID: input.OrganizationID, + Provider: input.Provider, + Protocol: coredata.ConnectorProtocolWorkloadIdentity, + Connection: &connector.WorkloadIdentityConnection{}, + RawSettings: raw, + }) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot create workload identity connector", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.CreateWorkloadIdentityConnectorPayload{ + Connector: types.NewConnector(cnnctr), + }, nil +} + // DeleteConnector is the resolver for the deleteConnector field. func (r *mutationResolver) DeleteConnector(ctx context.Context, input types.DeleteConnectorInput) (*types.DeleteConnectorPayload, error) { scope, err := r.authorize(ctx, input.ConnectorID, probo.ActionConnectorDelete) diff --git a/pkg/connector/provider/grant.go b/pkg/server/api/console/v1/connector_workload_identity.go similarity index 66% rename from pkg/connector/provider/grant.go rename to pkg/server/api/console/v1/connector_workload_identity.go index 99421872f5..e0113eb5e7 100644 --- a/pkg/connector/provider/grant.go +++ b/pkg/server/api/console/v1/connector_workload_identity.go @@ -18,27 +18,20 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package provider +package console_v1 import ( - "context" - - "go.probo.inc/probo/pkg/cloud" - "go.probo.inc/probo/pkg/coredata" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" + "go.probo.inc/probo/pkg/server/api/console/v1/types" ) -// InspectCloudGrant runs WorkloadIdentityConfig.InspectGrant when the -// provider registers one. A nil InspectGrant is a no-op: a successful assume -// is the whole check. -func (r *Registry) InspectCloudGrant( - ctx context.Context, - session cloud.Session, - conn *coredata.Connector, -) error { - reg, ok := r.Get(conn.Provider) - if !ok || reg.WorkloadIdentity == nil || reg.WorkloadIdentity.InspectGrant == nil { - return nil +func newAWSConnectorSetup(setup cloudaws.ConnectorSetup) *types.AWSConnectorSetup { + return &types.AWSConnectorSetup{ + Issuer: setup.Issuer, + Audience: setup.Audience, + Subject: setup.Subject, + SuggestedRoleName: setup.SuggestedRoleName, + TerraformSnippet: setup.TerraformSnippet, + CloudFormationQuickCreateURL: setup.CloudFormationQuickCreateURL, } - - return reg.WorkloadIdentity.InspectGrant(ctx, session, conn) } diff --git a/pkg/server/api/console/v1/graphql/base.graphql b/pkg/server/api/console/v1/graphql/base.graphql index d9cc0fa4bc..35adaf3300 100644 --- a/pkg/server/api/console/v1/graphql/base.graphql +++ b/pkg/server/api/console/v1/graphql/base.graphql @@ -49,6 +49,8 @@ type Query { viewer: Viewer! commonThirdParties(name: String!): [CommonThirdParty!]! accessReviewDrivers: [ConnectorProviderInfo!]! @goField(forceResolver: true) + awsConnectorSetup(organizationId: ID!): AWSConnectorSetup! + @goField(forceResolver: true) crispVerificationCode(organizationId: ID!, websiteId: String!): String! @goField(forceResolver: true) probotIdentityBindPreview(token: String!): ProbotIdentityBindPreview! diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index 277731c55d..fa139097d3 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -187,6 +187,27 @@ type ConnectorProviderInfo { client cannot render one path's settings on the other. """ clientCredentialsExtraSettings: [ConnectorProviderSettingInfo!]! + """ + workloadIdentitySupported is true when this provider connects by federating + a Probo-issued OIDC token into the customer's cloud account, and this + deployment has the identity federation issuer enabled. + """ + workloadIdentitySupported: Boolean! + """ + workloadIdentityExtraSettings lists the settings the workload-identity + connect form must render and submit, in render order. Empty when the + provider has no workload-identity path. + """ + workloadIdentityExtraSettings: [ConnectorProviderSettingInfo!]! +} + +type AWSConnectorSetup { + issuer: String! + audience: String! + subject: String! + suggestedRoleName: String! + terraformSnippet: String! + cloudFormationQuickCreateURL: String! } type ConnectorProviderSettingInfo { @@ -199,12 +220,31 @@ input ConnectorFilter { providers: [ConnectorProvider!] } +""" +Why a connector's credential is or is not usable right now. + +DISCONNECTED covers every reason the credential did not work — revoked, +expired, a role Probo cannot assume, a provider outage — because the +underlying failure wraps provider-controlled text and stays server-side. +""" +enum ConnectorConnectionStatus { + CONNECTED + DISCONNECTED + RECONNECT_REQUIRED +} + type Connector { id: ID! provider: ConnectorProvider! protocol: ConnectorProtocol! canReconnect: Boolean! @goField(forceResolver: true) oauth2Scopes: [String!]! @goField(forceResolver: true) + """ + connectionStatus probes the provider live on every read, so selecting it + across a list of connectors costs one outbound call per connector. Select + it on a single connector, or on the one a mutation just returned. + """ + connectionStatus: ConnectorConnectionStatus! @goField(forceResolver: true) createdAt: Datetime! } @@ -235,6 +275,9 @@ extend type Mutation { createClientCredentialsConnector( input: CreateClientCredentialsConnectorInput! ): CreateClientCredentialsConnectorPayload! + createWorkloadIdentityConnector( + input: CreateWorkloadIdentityConnectorInput! + ): CreateWorkloadIdentityConnectorPayload! deleteConnector(input: DeleteConnectorInput!): DeleteConnectorPayload! deleteSlackConnection( input: DeleteSlackConnectionInput! @@ -291,6 +334,16 @@ type CreateClientCredentialsConnectorPayload { connector: Connector } +input CreateWorkloadIdentityConnectorInput { + organizationId: ID! + provider: ConnectorProvider! + awsRoleArn: String! +} + +type CreateWorkloadIdentityConnectorPayload { + connector: Connector! +} + input DeleteConnectorInput { connectorId: ID! } diff --git a/pkg/server/api/console/v1/graphql_handler.go b/pkg/server/api/console/v1/graphql_handler.go index 569cef1c44..6eb01a20dc 100644 --- a/pkg/server/api/console/v1/graphql_handler.go +++ b/pkg/server/api/console/v1/graphql_handler.go @@ -28,6 +28,7 @@ import ( "go.probo.inc/probo/pkg/agentexecution" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/certmanager" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/complianceportal/management" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/connector/provider" @@ -35,6 +36,7 @@ import ( "go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/identityfederation" "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" @@ -75,6 +77,8 @@ func NewGraphQLHandler( slackbotInstallations *slackchannel.InstallationService, botDeliveryDestinations BotDeliveryDestinations, complianceMessages ComplianceMessages, + identityFederation *identityfederation.Issuer, + awsConnectorInstall cloudaws.ConnectorInstallConfig, ) http.Handler { config := schema.Config{ Resolvers: &Resolver{ @@ -100,6 +104,8 @@ func NewGraphQLHandler( baseURL: baseURL, itam: itamSvc, logger: logger, + identityFederation: identityFederation, + awsConnectorInstall: awsConnectorInstall, probotIdentityBindings: probotIdentityBindings, slackbotInstallations: slackbotInstallations, botDeliveryDestinations: botDeliveryDestinations, diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index cf0949e962..e43035ec59 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -31,6 +31,7 @@ import ( "go.probo.inc/probo/pkg/agentexecution" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/certmanager" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/complianceportal/management" "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/connector/provider" @@ -40,6 +41,7 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/identityfederation" "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" @@ -93,6 +95,8 @@ type ( baseURL *baseurl.BaseURL customDomainCname string tokenSecret string + identityFederation *identityfederation.Issuer + awsConnectorInstall cloudaws.ConnectorInstallConfig probotIdentityBindings *identitybinding.Service slackbotInstallations *slackchannel.InstallationService botDeliveryDestinations BotDeliveryDestinations @@ -127,6 +131,8 @@ func NewMux( complianceMessages ComplianceMessages, graphqlLimits gqlutils.Limits, itamSvc *itam.Service, + identityFederation *identityfederation.Issuer, + awsConnectorInstall cloudaws.ConnectorInstallConfig, ) *chi.Mux { r := chi.NewMux() @@ -158,6 +164,8 @@ func NewMux( slackbotInstallations, botDeliveryDestinations, complianceMessages, + identityFederation, + awsConnectorInstall, ) r.Group(func(r chi.Router) { diff --git a/pkg/server/api/mcp/v1/connector_connection_status.go b/pkg/server/api/mcp/v1/connector_connection_status.go new file mode 100644 index 0000000000..4bb0a39bc7 --- /dev/null +++ b/pkg/server/api/mcp/v1/connector_connection_status.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package mcp_v1 + +import ( + "context" + "errors" + + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/accessreview" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/server/api/mcp/v1/types" +) + +// connectorConnectionStatus reports whether the connector's credential is +// usable right now, by probing the provider and comparing the granted OAuth +// scopes against the current registration. +// +// MCP has no lazy field resolution, so this runs eagerly wherever a Connector +// is built. Every failure collapses to DISCONNECTED: the underlying error +// wraps provider-controlled text and customer-chosen hosts, and stays in the +// logs rather than travelling to the client. +func (r *Resolver) connectorConnectionStatus( + ctx context.Context, + scope coredata.Scoper, + connectorID gid.GID, +) types.ConnectorConnectionStatus { + if err := r.accessReview.ProbeConnector(ctx, scope, connectorID); err != nil { + field := log.String("connector_id", connectorID.String()) + + if probeErr, ok := errors.AsType[*accessreview.ProbeError](err); ok && probeErr != nil { + // Not Probo's failure, so not in the error budget. + r.logger.WarnCtx( + ctx, + "connector credential probe failed, reporting disconnected", + field, + log.String("provider", probeErr.Provider.String()), + log.String("probe_failure", accessreview.ProbeFailureCode(err)), + ) + + return types.ConnectorConnectionStatusDISCONNECTED + } + + r.logger.ErrorCtx( + ctx, + "cannot probe connector, reporting disconnected", + field, + log.Error(err), + ) + + return types.ConnectorConnectionStatusDISCONNECTED + } + + needsReconnect, err := r.accessReview.SourceNeedsReconnect(ctx, scope, connectorID) + if err != nil { + r.logger.ErrorCtx( + ctx, + "cannot determine connector reconnect requirement, reporting disconnected", + log.String("connector_id", connectorID.String()), + log.Error(err), + ) + + return types.ConnectorConnectionStatusDISCONNECTED + } + + if needsReconnect { + return types.ConnectorConnectionStatusRECONNECTREQUIRED + } + + return types.ConnectorConnectionStatusCONNECTED +} diff --git a/pkg/server/api/mcp/v1/resolver.go b/pkg/server/api/mcp/v1/resolver.go index b472cccb64..37ce40c691 100644 --- a/pkg/server/api/mcp/v1/resolver.go +++ b/pkg/server/api/mcp/v1/resolver.go @@ -32,12 +32,14 @@ import ( "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/certmanager" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/complianceportal/management" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/identityfederation" "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" @@ -54,20 +56,22 @@ import ( const maxDeviceListSize = 100 type Resolver struct { - proboSvc *probo.Service - management *management.Service - certManager *certmanager.Service - resourceAlias *resourcealias.Service - thirdPartySvc *thirdparty.Service - iamSvc *iam.Service - accessReview *accessreview.Service - cookieBanner *cookiebanner.Service - riskManagement *riskmanagement.Service - itamSvc *itam.Service - mailman *mailman.Service - logger *log.Logger - fileManager *filemanager.Service - baseURL *baseurl.BaseURL + proboSvc *probo.Service + management *management.Service + certManager *certmanager.Service + resourceAlias *resourcealias.Service + thirdPartySvc *thirdparty.Service + iamSvc *iam.Service + accessReview *accessreview.Service + cookieBanner *cookiebanner.Service + riskManagement *riskmanagement.Service + itamSvc *itam.Service + mailman *mailman.Service + logger *log.Logger + fileManager *filemanager.Service + baseURL *baseurl.BaseURL + identityFederation *identityfederation.Issuer + awsConnectorInstall cloudaws.ConnectorInstallConfig } func markdownToProseMirrorJSON(markdown string) (string, error) { diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index e9d62ac7f1..75aa7519b7 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -14,7 +14,9 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/accessreview" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/complianceportal/management" + "go.probo.inc/probo/pkg/connector" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" @@ -9322,3 +9324,75 @@ func mapTreatmentPlanError(ctx context.Context, logger *log.Logger, op string, e return fmt.Errorf("internal server error") } + +func (r *Resolver) AwsConnectorSetupTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AwsConnectorSetupInput) (*mcp.CallToolResult, types.AwsConnectorSetupOutput, error) { + if _, err := r.Authorize(ctx, input.OrganizationID, probo.ActionConnectorCreate); err != nil { + return nil, types.AwsConnectorSetupOutput{}, err + } + + if r.identityFederation == nil { + return nil, types.AwsConnectorSetupOutput{}, fmt.Errorf("identity federation is not configured in this deployment") + } + + setup, err := cloudaws.ConnectorSetupFor( + r.identityFederation, + input.OrganizationID, + r.awsConnectorInstall, + ) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot build aws connector setup", log.Error(err)) + + return nil, types.AwsConnectorSetupOutput{}, fmt.Errorf("internal server error") + } + + return nil, types.AwsConnectorSetupOutput{ + Setup: types.NewAWSConnectorSetup(setup), + }, nil +} + +func (r *Resolver) CreateWorkloadIdentityConnectorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.CreateWorkloadIdentityConnectorInput) (*mcp.CallToolResult, types.CreateWorkloadIdentityConnectorOutput, error) { + scope, err := r.Authorize(ctx, input.OrganizationID, probo.ActionConnectorCreate) + if err != nil { + return nil, types.CreateWorkloadIdentityConnectorOutput{}, err + } + + if r.identityFederation == nil { + return nil, types.CreateWorkloadIdentityConnectorOutput{}, fmt.Errorf("identity federation is not configured in this deployment") + } + + if input.Provider != coredata.ConnectorProviderAWS { + return nil, types.CreateWorkloadIdentityConnectorOutput{}, fmt.Errorf("provider does not support workload identity") + } + + settings, err := cloudaws.NewConnectorSettings(input.AwsRoleArn) + if err != nil { + return nil, types.CreateWorkloadIdentityConnectorOutput{}, err + } + + raw, err := json.Marshal(settings) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot marshal aws connector settings", log.Error(err)) + + return nil, types.CreateWorkloadIdentityConnectorOutput{}, fmt.Errorf("internal server error") + } + + cnnctr, err := r.proboSvc.Connectors.Create(ctx, scope, probo.CreateConnectorRequest{ + OrganizationID: input.OrganizationID, + Provider: input.Provider, + Protocol: coredata.ConnectorProtocolWorkloadIdentity, + Connection: &connector.WorkloadIdentityConnection{}, + RawSettings: raw, + }) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot create workload identity connector", log.Error(err)) + + return nil, types.CreateWorkloadIdentityConnectorOutput{}, fmt.Errorf("internal server error") + } + + return nil, types.CreateWorkloadIdentityConnectorOutput{ + Connector: types.NewConnector( + cnnctr, + r.connectorConnectionStatus(ctx, scope, cnnctr.ID), + ), + }, nil +} diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index cadb503a0e..e547fab3c0 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -9874,6 +9874,121 @@ components: $ref: "#/components/schemas/GID" description: Deleted access source ID + ConnectorConnectionStatus: + type: string + enum: + - CONNECTED + - DISCONNECTED + - RECONNECT_REQUIRED + description: >- + Whether the connector credential is usable right now. DISCONNECTED + covers every reason it did not work, because the underlying failure + wraps provider-controlled text and stays server-side. + + Connector: + type: object + required: + - id + - provider + - protocol + - connection_status + - created_at + properties: + id: + $ref: "#/components/schemas/GID" + description: Connector ID + provider: + type: string + description: Connector provider + protocol: + type: string + description: Connector protocol + connection_status: + $ref: "#/components/schemas/ConnectorConnectionStatus" + description: Whether the connector credential is usable right now + created_at: + type: string + format: date-time + description: Creation timestamp + + AWSConnectorSetup: + type: object + required: + - issuer + - audience + - subject + - suggested_role_name + - terraform_snippet + - cloud_formation_quick_create_url + properties: + issuer: + type: string + description: Organization issuer URL + audience: + type: string + description: Token audience + subject: + type: string + description: Token subject + suggested_role_name: + type: string + description: Default IAM role name + terraform_snippet: + type: string + description: Terraform module block with issuer, subject and role name filled + cloud_formation_quick_create_url: + type: string + description: AWS console quick-create URL with issuer, subject and role name prefilled + + AWSConnectorSetupMCPInput: + type: object + required: + - organization_id + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + + AWSConnectorSetupMCPOutput: + type: object + required: + - setup + properties: + setup: + $ref: "#/components/schemas/AWSConnectorSetup" + + WorkloadIdentityConnectorProvider: + type: string + enum: + - AWS + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.ConnectorProvider + description: Connector provider that supports workload identity + + CreateWorkloadIdentityConnectorMCPInput: + type: object + required: + - organization_id + - provider + - aws_role_arn + properties: + organization_id: + $ref: "#/components/schemas/GID" + description: Organization ID + provider: + $ref: "#/components/schemas/WorkloadIdentityConnectorProvider" + description: Connector provider (AWS) + aws_role_arn: + type: string + description: IAM role ARN, including partition, account, and role name + + CreateWorkloadIdentityConnectorMCPOutput: + type: object + required: + - connector + properties: + connector: + $ref: "#/components/schemas/Connector" + CreateAccessReviewCampaignMCPInput: type: object required: @@ -17391,6 +17506,30 @@ tools: $ref: "#/components/schemas/DeleteAccessReviewSourceMCPInput" outputSchema: $ref: "#/components/schemas/DeleteAccessReviewSourceMCPOutput" + - name: awsConnectorSetup + title: AWS Connector Setup + description: Return issuer, subject, audience and deploy artifacts for connecting an AWS account + hints: + readonly: true + destructive: false + idempotent: true + openWorld: false + inputSchema: + $ref: "#/components/schemas/AWSConnectorSetupMCPInput" + outputSchema: + $ref: "#/components/schemas/AWSConnectorSetupMCPOutput" + - name: createWorkloadIdentityConnector + title: Create Workload Identity Connector + description: Create a workload-identity connector (AWS) and report whether the audit role can be assumed + hints: + readonly: false + destructive: false + idempotent: false + openWorld: true + inputSchema: + $ref: "#/components/schemas/CreateWorkloadIdentityConnectorMCPInput" + outputSchema: + $ref: "#/components/schemas/CreateWorkloadIdentityConnectorMCPOutput" - name: createAccessReviewCampaign title: Create Access Review Campaign description: Create a new access review campaign for an organization diff --git a/pkg/server/api/mcp/v1/types/connector.go b/pkg/server/api/mcp/v1/types/connector.go new file mode 100644 index 0000000000..67e5274f02 --- /dev/null +++ b/pkg/server/api/mcp/v1/types/connector.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package types + +import ( + cloudaws "go.probo.inc/probo/pkg/cloud/aws" + "go.probo.inc/probo/pkg/coredata" +) + +func NewConnector(c *coredata.Connector, status ConnectorConnectionStatus) *Connector { + return &Connector{ + ID: c.ID, + Provider: string(c.Provider), + Protocol: string(c.Protocol), + ConnectionStatus: new(status), + CreatedAt: c.CreatedAt, + } +} + +func NewAWSConnectorSetup(setup cloudaws.ConnectorSetup) *AWSConnectorSetup { + return &AWSConnectorSetup{ + Issuer: setup.Issuer, + Audience: setup.Audience, + Subject: setup.Subject, + SuggestedRoleName: setup.SuggestedRoleName, + TerraformSnippet: setup.TerraformSnippet, + CloudFormationQuickCreateURL: setup.CloudFormationQuickCreateURL, + } +} diff --git a/pkg/server/api/mcp/v1/v1_handler.go b/pkg/server/api/mcp/v1/v1_handler.go index 78c4c41e93..ddf79ff9b1 100644 --- a/pkg/server/api/mcp/v1/v1_handler.go +++ b/pkg/server/api/mcp/v1/v1_handler.go @@ -31,10 +31,12 @@ import ( "go.probo.inc/probo/pkg/accessreview" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/certmanager" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/complianceportal/management" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/identityfederation" "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" @@ -62,26 +64,30 @@ func NewMux( tokenSecret string, fileManagerSvc *filemanager.Service, baseURL *baseurl.BaseURL, + identityFederation *identityfederation.Issuer, + awsConnectorInstall cloudaws.ConnectorInstallConfig, ) *chi.Mux { logger = logger.Named("mcp.v1") logger.Info("initializing MCP server") resolver := &Resolver{ - proboSvc: proboSvc, - management: managementSvc, - certManager: certManagerSvc, - resourceAlias: resourceAliasSvc, - thirdPartySvc: thirdPartySvc, - iamSvc: iamSvc, - accessReview: accessReviewSvc, - cookieBanner: cookieBannerSvc, - riskManagement: riskManagementSvc, - itamSvc: itamSvc, - mailman: mailmanSvc, - logger: logger, - fileManager: fileManagerSvc, - baseURL: baseURL, + proboSvc: proboSvc, + management: managementSvc, + certManager: certManagerSvc, + resourceAlias: resourceAliasSvc, + thirdPartySvc: thirdPartySvc, + iamSvc: iamSvc, + accessReview: accessReviewSvc, + cookieBanner: cookieBannerSvc, + riskManagement: riskManagementSvc, + itamSvc: itamSvc, + mailman: mailmanSvc, + logger: logger, + fileManager: fileManagerSvc, + baseURL: baseURL, + identityFederation: identityFederation, + awsConnectorInstall: awsConnectorInstall, } mcpServer := server.New(resolver, mcpgenmcp.WithRecoverFunc(mcputils.NewRecoverFunc(logger))) diff --git a/pkg/server/server.go b/pkg/server/server.go index 18ad84834d..e24a8eac1d 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -33,6 +33,7 @@ import ( "go.probo.inc/probo/pkg/agentexecution" "go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/certmanager" + cloudaws "go.probo.inc/probo/pkg/cloud/aws" "go.probo.inc/probo/pkg/complianceportal/management" "go.probo.inc/probo/pkg/complianceportal/visitor" "go.probo.inc/probo/pkg/connector" @@ -104,6 +105,7 @@ type Config struct { // IdentityFederationIssuer serves the outbound OIDC documents. It is nil when the // identity federation issuer is disabled, in which case no /federation route exists. IdentityFederationIssuer *identityfederation.Issuer + AWSConnectorInstall cloudaws.ConnectorInstallConfig } type Server struct { @@ -125,39 +127,41 @@ type Server struct { func NewServer(cfg Config) (*Server, error) { apiCfg := api.Config{ - BaseURL: cfg.BaseURL, - AllowedOrigins: cfg.AllowedOrigins, - Probo: cfg.Probo, - ResourceAlias: cfg.ResourceAlias, - File: cfg.File, - IAM: cfg.IAM, - Visitor: cfg.Visitor, - ESign: cfg.ESign, - Management: cfg.Management, - CertManager: cfg.CertManager, - AccessReview: cfg.AccessReview, - AgentExecution: cfg.AgentExecution, - Slack: cfg.Slack, - BotDeliveryDestinations: cfg.BotDeliveryDestinations, - ComplianceMessages: cfg.ComplianceMessages, - Slackbot: cfg.Slackbot, - SlackInteractiveInbox: cfg.SlackInteractiveInbox, - ProbotIdentityBindings: cfg.ProbotIdentityBindings, - SlackbotInstallations: cfg.SlackbotInstallations, - ProbotCapabilities: cfg.ProbotCapabilities, - Mailman: cfg.Mailman, - CookieBanner: cfg.CookieBanner, - Geoloc: cfg.Geoloc, - ThirdParty: cfg.ThirdParty, - RiskManagement: cfg.RiskManagement, - ITAM: cfg.ITAM, - Cookie: cfg.Cookie, - TokenSecret: cfg.TokenSecret, - ConnectorRegistry: cfg.ConnectorRegistry, - ProviderRegistry: cfg.ProviderRegistry, - CustomDomainCname: cfg.CustomDomainCname, - GraphQLLimits: cfg.GraphQLLimits, - Logger: cfg.Logger.Named("api"), + BaseURL: cfg.BaseURL, + AllowedOrigins: cfg.AllowedOrigins, + Probo: cfg.Probo, + ResourceAlias: cfg.ResourceAlias, + File: cfg.File, + IAM: cfg.IAM, + Visitor: cfg.Visitor, + ESign: cfg.ESign, + Management: cfg.Management, + CertManager: cfg.CertManager, + AccessReview: cfg.AccessReview, + AgentExecution: cfg.AgentExecution, + Slack: cfg.Slack, + BotDeliveryDestinations: cfg.BotDeliveryDestinations, + ComplianceMessages: cfg.ComplianceMessages, + Slackbot: cfg.Slackbot, + SlackInteractiveInbox: cfg.SlackInteractiveInbox, + ProbotIdentityBindings: cfg.ProbotIdentityBindings, + SlackbotInstallations: cfg.SlackbotInstallations, + ProbotCapabilities: cfg.ProbotCapabilities, + Mailman: cfg.Mailman, + CookieBanner: cfg.CookieBanner, + Geoloc: cfg.Geoloc, + ThirdParty: cfg.ThirdParty, + RiskManagement: cfg.RiskManagement, + ITAM: cfg.ITAM, + Cookie: cfg.Cookie, + TokenSecret: cfg.TokenSecret, + ConnectorRegistry: cfg.ConnectorRegistry, + ProviderRegistry: cfg.ProviderRegistry, + CustomDomainCname: cfg.CustomDomainCname, + GraphQLLimits: cfg.GraphQLLimits, + Logger: cfg.Logger.Named("api"), + IdentityFederationIssuer: cfg.IdentityFederationIssuer, + AWSConnectorInstall: cfg.AWSConnectorInstall, } apiServer, err := api.NewServer(apiCfg)