diff --git a/frontend/common/services/useMasterAPIKeyWithMasterAPIKeyRole.ts b/frontend/common/services/useMasterAPIKeyWithMasterAPIKeyRole.ts index 0f948f2c1f4b..fb85d2204758 100644 --- a/frontend/common/services/useMasterAPIKeyWithMasterAPIKeyRole.ts +++ b/frontend/common/services/useMasterAPIKeyWithMasterAPIKeyRole.ts @@ -87,7 +87,7 @@ export async function getRolesMasterAPIKeyWithMasterAPIKeyRoles( export async function deleteMasterAPIKeyWithMasterAPIKeyRoles( store: any, - data: Req['getMasterAPIKeyWithMasterAPIKeyRoles'], + data: Req['deleteMasterAPIKeyWithMasterAPIKeyRoles'], options?: Parameters< typeof masterAPIKeyWithMasterAPIKeyRoleService.endpoints.deleteMasterAPIKeyWithMasterAPIKeyRoles.initiate >[1], diff --git a/frontend/common/services/useTrustRelationship.ts b/frontend/common/services/useTrustRelationship.ts new file mode 100644 index 000000000000..367727b98662 --- /dev/null +++ b/frontend/common/services/useTrustRelationship.ts @@ -0,0 +1,87 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const trustRelationshipService = service + .enhanceEndpoints({ + addTagTypes: ['TrustRelationship'], + }) + .injectEndpoints({ + endpoints: (builder) => ({ + createTrustRelationship: builder.mutation< + Res['trustRelationship'], + Req['createTrustRelationship'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'TrustRelationship' }], + query: (query: Req['createTrustRelationship']) => ({ + body: query.body, + method: 'POST', + url: `organisations/${query.organisation_id}/trust-relationships/`, + }), + }), + deleteTrustRelationship: builder.mutation< + void, + Req['deleteTrustRelationship'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'TrustRelationship' }], + query: (query: Req['deleteTrustRelationship']) => ({ + method: 'DELETE', + url: `organisations/${query.organisation_id}/trust-relationships/${query.id}/`, + }), + }), + getTrustRelationships: builder.query< + Res['trustRelationships'], + Req['getTrustRelationships'] + >({ + providesTags: [{ id: 'LIST', type: 'TrustRelationship' }], + query: (query: Req['getTrustRelationships']) => ({ + url: `organisations/${query.organisation_id}/trust-relationships/`, + }), + }), + updateTrustRelationship: builder.mutation< + Res['trustRelationship'], + Req['updateTrustRelationship'] + >({ + invalidatesTags: (res) => [ + { id: 'LIST', type: 'TrustRelationship' }, + { id: res?.id, type: 'TrustRelationship' }, + ], + query: (query: Req['updateTrustRelationship']) => ({ + body: query.body, + method: 'PUT', + url: `organisations/${query.organisation_id}/trust-relationships/${query.id}/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getTrustRelationships( + store: any, + data: Req['getTrustRelationships'], + options?: Parameters< + typeof trustRelationshipService.endpoints.getTrustRelationships.initiate + >[1], +) { + return store.dispatch( + trustRelationshipService.endpoints.getTrustRelationships.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateTrustRelationshipMutation, + useDeleteTrustRelationshipMutation, + useGetTrustRelationshipsQuery, + useUpdateTrustRelationshipMutation, + // END OF EXPORTS +} = trustRelationshipService + +/* Usage examples: +const { data, isLoading } = useGetTrustRelationshipsQuery({ organisation_id: 2 }) //get hook +const [createTrustRelationship, { isLoading, data, isSuccess }] = useCreateTrustRelationshipMutation() //create hook +trustRelationshipService.endpoints.getTrustRelationships.select({organisation_id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index fe7053e9cfbb..d6a25d9cd1ff 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -33,6 +33,7 @@ import { TagStrategy, FeatureType, LifecycleStage, + TrustRelationshipClaimRule, } from './responses' import { UtmsType } from './utms' @@ -494,7 +495,11 @@ export type Req = { getRoleMasterApiKey: { org_id: number; role_id: number; id: string } updateRoleMasterApiKey: { org_id: number; role_id: number; id: string } deleteRoleMasterApiKey: { org_id: number; role_id: number; id: string } - createRoleMasterApiKey: { org_id: number; role_id: number } + createRoleMasterApiKey: { + org_id: number + role_id: number + body: { master_api_key: string } + } getMasterAPIKeyWithMasterAPIKeyRoles: { org_id: number; prefix: string } deleteMasterAPIKeyWithMasterAPIKeyRoles: { org_id: number @@ -502,6 +507,29 @@ export type Req = { role_id: number } getRolesMasterAPIKeyWithMasterAPIKeyRoles: { org_id: number; prefix: string } + getTrustRelationships: { organisation_id: number } + createTrustRelationship: { + organisation_id: number + body: { + name: string + issuer: string + audience: string + claim_rules: TrustRelationshipClaimRule[] + is_admin: boolean + } + } + updateTrustRelationship: { + organisation_id: number + id: number + body: { + name: string + issuer: string + audience: string + claim_rules: TrustRelationshipClaimRule[] + is_admin: boolean + } + } + deleteTrustRelationship: { organisation_id: number; id: number } createLaunchDarklyProjectImport: { project_id: number body: { diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index fa51e33faee2..4f74607db5e2 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1293,6 +1293,24 @@ export type WarehouseConnection = { unique_events_count: number | null } +export type TrustRelationshipClaimRule = { + claim: string + values: string[] +} + +export type TrustRelationship = { + id: number + name: string + issuer: string + audience: string + claim_rules: TrustRelationshipClaimRule[] + is_admin: boolean + master_api_key_id: string + master_api_key_prefix: string + created_at: string + created_by: number | null +} + export type Res = { segments: PagedResponse segment: Segment @@ -1383,6 +1401,8 @@ export type Res = { launchDarklyProjectImport: LaunchDarklyProjectImport launchDarklyProjectsImport: LaunchDarklyProjectImport[] roleMasterApiKey: { id: number; master_api_key: string; role: number } + trustRelationship: TrustRelationship + trustRelationships: PagedResponse masterAPIKeyWithMasterAPIKeyRoles: { id: string prefix: string diff --git a/frontend/web/components/AdminAPIKeys.js b/frontend/web/components/AdminAPIKeys.js index 45c9f13d4a0f..d5d43748d3de 100644 --- a/frontend/web/components/AdminAPIKeys.js +++ b/frontend/web/components/AdminAPIKeys.js @@ -7,6 +7,8 @@ import OrganisationStore from 'common/stores/organisation-store' import getUserDisplayName from 'common/utils/getUserDisplayName' import Token from './Token' import JSONReference from './JSONReference' +import PageTitle from './PageTitle' +import PlanBasedBanner from './PlanBasedAccess' import Button from './base/forms/Button' import DateSelect from './DateSelect' import Icon from './icons/Icon' @@ -201,8 +203,8 @@ export class CreateAPIKey extends PureComponent { /> <> - - + + { this.setState({ @@ -212,7 +214,13 @@ export class CreateAPIKey extends PureComponent { checked={is_admin} disabled={!Utils.getPlansPermission('RBAC') && is_admin} /> + + {!is_admin && ( <> @@ -439,12 +447,19 @@ export default class AdminAPIKeys extends PureComponent { title={'API Keys'} json={apiKeys} /> - -
{`${'Manage'} API Keys`}
-

- {`API keys are used to authenticate with the Admin API.`} -

-
+
+ + {`Create API Key`} + + } + > + {`API keys are used to authenticate with the Admin API. `} -
- - + +
{(this.state.isLoading || !OrganisationStore.model?.users) && (
diff --git a/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx b/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx index 519e0ba6177e..2190e3d528c4 100644 --- a/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx +++ b/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx @@ -40,15 +40,20 @@ const OrganisationSettingsPage: FC = () => { API.trackPage(Constants.pages.ORGANISATION_SETTINGS) }, []) - // Back-compat: the SAML tab was renamed to SSO. Existing bookmarks and - // links pointing at ?tab=saml redirect to ?tab=sso so users land in the - // right place. + // Back-compat: renamed tabs redirect so existing bookmarks and links land + // in the right place (SAML became SSO; the API Keys tab became API Access + // with the `keys` slug). const history = useHistory() const location = useLocation() useEffect(() => { + const legacyTabs: Record = { + 'api-keys': 'keys', + 'saml': 'sso', + } const params = new URLSearchParams(location.search) - if (params.get('tab') === 'saml') { - params.set('tab', 'sso') + const redirectTo = legacyTabs[params.get('tab') || ''] + if (redirectTo) { + params.set('tab', redirectTo) history.replace(`${location.pathname}?${params.toString()}`) } }, [history, location]) @@ -116,7 +121,7 @@ const OrganisationSettingsPage: FC = () => { component: , isVisible: true, key: 'keys', - label: 'API Keys', + label: 'API Access', }, { component: , @@ -137,7 +142,12 @@ const OrganisationSettingsPage: FC = () => { {tabs.map(({ component, key, label }) => ( - + {component} ))} diff --git a/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx index 7db70e515a2d..be4b4b0e2cc3 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx @@ -1,10 +1,19 @@ import React from 'react' import AdminAPIKeys from 'components/AdminAPIKeys' +import Utils from 'common/utils/utils' +import TrustRelationships from './trust-relationships' type APIKeysTabProps = { organisationId: number } export const APIKeysTab = ({ organisationId }: APIKeysTabProps) => { - return + return ( + <> + + {Utils.getFlagsmithHasFeature('trust_relationships') && ( + + )} + + ) } diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/GithubTrustRelationshipForm.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/GithubTrustRelationshipForm.tsx new file mode 100644 index 000000000000..6de342b8a022 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/GithubTrustRelationshipForm.tsx @@ -0,0 +1,305 @@ +import React, { FC, useEffect, useMemo, useState } from 'react' +import Button from 'components/base/forms/Button' +import ErrorMessage from 'components/ErrorMessage' +import Input from 'components/base/forms/Input' +import InputGroup from 'components/base/forms/InputGroup' +import Utils from 'common/utils/utils' +import { + Repository, + TrustRelationship, + TrustRelationshipClaimRule, +} from 'common/types/responses' +import { useGetGithubIntegrationQuery } from 'common/services/useGithubIntegration' +import { useGetGithubReposQuery } from 'common/services/useGithub' +import { + useCreateTrustRelationshipMutation, + useUpdateTrustRelationshipMutation, +} from 'common/services/useTrustRelationship' +import useTrustRelationshipRoles from 'components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles' +import TrustRelationshipPermissionsFields from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' +import WorkflowSetupSnippet, { + GITHUB_ISSUER, +} from 'components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet' + +type GithubTrustRelationshipFormProps = { + organisationId: number + existingAudiences: string[] + trustRelationship?: TrustRelationship +} + +const ruleValue = ( + trustRelationship: TrustRelationship | undefined, + claim: string, +): string | undefined => + trustRelationship?.claim_rules.find((rule) => rule.claim === claim)?.values[0] + +const GithubTrustRelationshipForm: FC = ({ + existingAudiences, + organisationId, + trustRelationship, +}) => { + const isEdit = !!trustRelationship + const { data: githubIntegrations } = useGetGithubIntegrationQuery({ + organisation_id: organisationId, + }) + const installationId = githubIntegrations?.results?.[0]?.installation_id + const { data: repos } = useGetGithubReposQuery( + { installation_id: installationId || '', organisation_id: organisationId }, + { skip: !installationId }, + ) + + const initialRepoFullName = ruleValue(trustRelationship, 'repository') || '' + const pinnedRepoId = ruleValue(trustRelationship, 'repository_id') + + const [selectedRepo, setSelectedRepo] = useState(null) + const [manualOwner, setManualOwner] = useState( + initialRepoFullName.split('/')[0] || '', + ) + const [manualRepo, setManualRepo] = useState( + initialRepoFullName.split('/')[1] || '', + ) + const [environment, setEnvironment] = useState( + ruleValue(trustRelationship, 'environment') || '', + ) + const [isAdmin, setIsAdmin] = useState(trustRelationship?.is_admin ?? true) + const { addRole, assignRoles, clearRoles, removeRole, roles } = + useTrustRelationshipRoles(organisationId, trustRelationship) + + // A repository pinned by ID resolves through the installation's repo list. + const pinnedRepo = useMemo( + () => + pinnedRepoId + ? repos?.results?.find((repo) => `${repo.id}` === pinnedRepoId) + : undefined, + [pinnedRepoId, repos], + ) + useEffect(() => { + if (pinnedRepo && !selectedRepo) { + setSelectedRepo(pinnedRepo) + } + }, [pinnedRepo, selectedRepo]) + + const [ + createTrustRelationship, + { error: createError, isLoading: isCreating }, + ] = useCreateTrustRelationshipMutation() + const [ + updateTrustRelationship, + { error: updateError, isLoading: isUpdating }, + ] = useUpdateTrustRelationshipMutation() + + const isUnresolvedPin = !!pinnedRepoId && !pinnedRepo + const owner = selectedRepo?.owner?.login || manualOwner.trim() + const manualFullName = + manualOwner.trim() && manualRepo.trim() + ? `${manualOwner.trim()}/${manualRepo.trim()}` + : '' + const repoFullName = selectedRepo ? selectedRepo.full_name : manualFullName + const repoChanged = + !isEdit || (!isUnresolvedPin && repoFullName !== initialRepoFullName) + + // GitHub's default `aud` is the repository owner's URL, so the first trust + // relationship per owner needs no audience configuration in the workflow. + const audience = useMemo(() => { + if (isEdit && !repoChanged) return trustRelationship.audience + if (!owner) return '' + const otherAudiences = existingAudiences.filter( + (existing) => existing !== trustRelationship?.audience, + ) + const ownerAudience = `https://github.com/${owner}` + return otherAudiences.includes(ownerAudience) + ? `https://github.com/${repoFullName}` + : ownerAudience + }, [ + isEdit, + repoChanged, + trustRelationship, + owner, + repoFullName, + existingAudiences, + ]) + + const save = () => { + const claimRules: TrustRelationshipClaimRule[] = [] + if (isUnresolvedPin) { + claimRules.push({ claim: 'repository_id', values: [pinnedRepoId] }) + } else if (selectedRepo) { + // The numeric repository id survives renames; names are reclaimable. + claimRules.push({ + claim: 'repository_id', + values: [`${selectedRepo.id}`], + }) + } else { + claimRules.push({ claim: 'repository', values: [repoFullName] }) + } + if (environment.trim()) { + claimRules.push({ claim: 'environment', values: [environment.trim()] }) + } + const body = { + audience, + claim_rules: claimRules, + is_admin: isAdmin, + issuer: GITHUB_ISSUER, + name: + isUnresolvedPin && trustRelationship + ? trustRelationship.name + : `GitHub Actions (${repoFullName})`, + } + if (isEdit) { + updateTrustRelationship({ + body, + id: trustRelationship.id, + organisation_id: organisationId, + }) + .unwrap() + .then(() => { + toast('Trust relationship updated') + closeModal() + }) + .catch(() => null) + } else { + createTrustRelationship({ body, organisation_id: organisationId }) + .unwrap() + .then((created) => assignRoles(created.master_api_key_id)) + .then((allAssigned) => { + if (allAssigned) { + toast('Trust relationship created') + } else { + toast( + 'Trust relationship created, but some roles could not be assigned', + 'danger', + ) + } + closeModal() + }) + .catch(() => null) + } + } + + let repositoryFields = ( + <> + + setManualOwner(Utils.safeParseEventValue(e)) + } + placeholder='e.g. YourOrg' + /> + + setManualRepo(Utils.safeParseEventValue(e)) + } + placeholder='e.g. your-repo' + /> +
+ Install the{' '} + {' '} + to pick repositories from a list. +
+ + ) + if (isUnresolvedPin) { + repositoryFields = ( + + } + /> + ) + } else if (installationId) { + repositoryFields = ( + ({ + label: repo.full_name, + repo, + value: repo.full_name, + }))} + onChange={(option: { repo: Repository }) => + setSelectedRepo(option.repo) + } + /> + } + /> + ) + } + + return ( +
+ {repositoryFields} + + setEnvironment(Utils.safeParseEventValue(e)) + } + placeholder='e.g. production' + /> + {!!audience && ( + <> + + + + + )} + { + if (!isAdmin) { + clearRoles() + } + setIsAdmin(!isAdmin) + }} + roles={roles} + onAddRole={addRole} + onRemoveRole={removeRole} + /> + +
+ +
+
+ ) +} + +export default GithubTrustRelationshipForm diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/index.ts new file mode 100644 index 000000000000..23cd2e2b81a3 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/index.ts @@ -0,0 +1 @@ +export { default } from './GithubTrustRelationshipForm' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/NewTrustRelationshipModal.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/NewTrustRelationshipModal.tsx new file mode 100644 index 000000000000..fa474a2cc5f3 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/NewTrustRelationshipModal.tsx @@ -0,0 +1,68 @@ +import React, { FC, useState } from 'react' +import GithubTrustRelationshipForm from 'components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm' +import TrustRelationshipModal from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal' + +type Provider = 'github' | 'other' + +type NewTrustRelationshipModalProps = { + organisationId: number + existingAudiences: string[] +} + +const NewTrustRelationshipModal: FC = ({ + existingAudiences, + organisationId, +}) => { + const [provider, setProvider] = useState(null) + + if (provider === 'github') { + return ( + + ) + } + if (provider === 'other') { + return + } + + return ( +
+
setProvider('github')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') setProvider('github') + }} + > +
GitHub Actions
+
+ Let workflows in a GitHub repository authenticate with their OIDC job + token. Recommended if your CI runs on GitHub Actions. +
+
+
setProvider('other')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') setProvider('other') + }} + > +
Other OIDC provider
+
+ Configure a custom issuer, audience and claim matching rules for any + OIDC identity provider, such as GitLab CI or Kubernetes. +
+
+
+ ) +} + +export default NewTrustRelationshipModal diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/index.ts new file mode 100644 index 000000000000..13ce30e55aa7 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/index.ts @@ -0,0 +1 @@ +export { default } from './NewTrustRelationshipModal' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/TrustRelationshipModal.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/TrustRelationshipModal.tsx new file mode 100644 index 000000000000..13fe663b711a --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/TrustRelationshipModal.tsx @@ -0,0 +1,240 @@ +import React, { FC, useMemo, useState } from 'react' +import { IonIcon } from '@ionic/react' +import { close as closeIcon } from 'ionicons/icons' +import Button from 'components/base/forms/Button' +import ErrorMessage from 'components/ErrorMessage' +import Input from 'components/base/forms/Input' +import InputGroup from 'components/base/forms/InputGroup' +import Utils from 'common/utils/utils' +import { TrustRelationship } from 'common/types/responses' +import { + useCreateTrustRelationshipMutation, + useUpdateTrustRelationshipMutation, +} from 'common/services/useTrustRelationship' +import useTrustRelationshipRoles from 'components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles' +import TrustRelationshipPermissionsFields from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' +import WorkflowSetupSnippet, { + GITHUB_ISSUER, +} from 'components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet' + +type ClaimRuleRow = { claim: string; values: string } + +type TrustRelationshipModalProps = { + organisationId: number + trustRelationship?: TrustRelationship +} + +const TrustRelationshipModal: FC = ({ + organisationId, + trustRelationship, +}) => { + const isEdit = !!trustRelationship + const [name, setName] = useState(trustRelationship?.name || '') + const [issuer, setIssuer] = useState(trustRelationship?.issuer || '') + const [audience, setAudience] = useState(trustRelationship?.audience || '') + const [claimRules, setClaimRules] = useState( + trustRelationship?.claim_rules.map((rule) => ({ + claim: rule.claim, + values: rule.values.join(', '), + })) || [], + ) + const [isAdmin, setIsAdmin] = useState(trustRelationship?.is_admin ?? true) + const { addRole, assignRoles, clearRoles, removeRole, roles } = + useTrustRelationshipRoles(organisationId, trustRelationship) + + const [ + createTrustRelationship, + { error: createError, isLoading: isCreating }, + ] = useCreateTrustRelationshipMutation() + const [ + updateTrustRelationship, + { error: updateError, isLoading: isUpdating }, + ] = useUpdateTrustRelationshipMutation() + const fieldErrors = ((createError || updateError) as { data?: any })?.data + // Field-keyed errors render inline on their inputs; the alert carries + // whatever has no field to attach to. + const alertError = useMemo(() => { + const error = (createError || updateError) as { data?: any } | undefined + const data = error?.data + if (data && typeof data === 'object' && !Array.isArray(data)) { + const rest = Object.fromEntries( + Object.entries(data).filter( + ([key]) => !['audience', 'issuer', 'name'].includes(key), + ), + ) + return Object.keys(rest).length ? { ...error, data: rest } : null + } + return error + }, [createError, updateError]) + + const onIsAdminChange = () => { + // Turning admin on detaches any assigned roles server-side. + if (!isAdmin) { + clearRoles() + } + setIsAdmin(!isAdmin) + } + + const body = { + audience, + claim_rules: claimRules + .filter((rule) => rule.claim) + .map((rule) => ({ + claim: rule.claim.trim(), + values: rule.values + .split(',') + .map((value) => value.trim()) + .filter(Boolean), + })), + is_admin: isAdmin, + issuer, + name, + } + + const save = () => { + if (isEdit) { + updateTrustRelationship({ + body, + id: trustRelationship.id, + organisation_id: organisationId, + }) + .unwrap() + .then(() => { + toast('Trust relationship updated') + closeModal() + }) + .catch(() => null) + } else { + createTrustRelationship({ + body, + organisation_id: organisationId, + }) + .unwrap() + .then((created) => assignRoles(created.master_api_key_id)) + .then((allAssigned) => { + if (allAssigned) { + toast('Trust relationship created') + } else { + toast( + 'Trust relationship created, but some roles could not be assigned', + 'danger', + ) + } + closeModal() + }) + .catch(() => null) + } + } + + return ( +
+ setName(Utils.safeParseEventValue(e))} + placeholder='e.g. GitHub Actions' + /> + setIssuer(Utils.safeParseEventValue(e))} + placeholder='e.g. https://token.actions.githubusercontent.com' + /> + setAudience(Utils.safeParseEventValue(e))} + placeholder='e.g. https://github.com/YourOrg' + /> + + {claimRules.map((rule, index) => ( + + + + setClaimRules((rules) => + rules.map((r, i) => + i === index + ? { ...r, claim: Utils.safeParseEventValue(e) } + : r, + ), + ) + } + placeholder='Claim, e.g. repository' + /> + + + + setClaimRules((rules) => + rules.map((r, i) => + i === index + ? { ...r, values: Utils.safeParseEventValue(e) } + : r, + ), + ) + } + placeholder='Values, e.g. YourOrg/your-repo' + /> + + + + ))} +
+ Values support * wildcards; separate alternatives with commas. +
+ + {issuer.trim().replace(/\/+$/, '') === GITHUB_ISSUER && !!audience && ( + rule.claim === 'environment') + ?.values.split(',')[0] + ?.trim() || undefined + } + /> + )} + + +
+ +
+
+ ) +} + +export default TrustRelationshipModal diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/index.ts new file mode 100644 index 000000000000..b7345ad9e43f --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/index.ts @@ -0,0 +1 @@ +export { default } from './TrustRelationshipModal' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/TrustRelationshipPermissionsFields.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/TrustRelationshipPermissionsFields.tsx new file mode 100644 index 000000000000..8f13ef019935 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/TrustRelationshipPermissionsFields.tsx @@ -0,0 +1,84 @@ +import React, { FC, useState } from 'react' +import { IonIcon } from '@ionic/react' +import { close as closeIcon } from 'ionicons/icons' +import Button from 'components/base/forms/Button' +import PlanBasedBanner from 'components/PlanBasedAccess' +import Switch from 'components/Switch' +import MyRoleSelect from 'components/MyRoleSelect' +import Utils from 'common/utils/utils' + +export type SelectedRole = { id: number; name: string } + +type TrustRelationshipPermissionsFieldsProps = { + organisationId: number + isAdmin: boolean + onIsAdminChange: () => void + roles: SelectedRole[] + onAddRole: (role: SelectedRole) => void + onRemoveRole: (roleId: number) => void +} + +const TrustRelationshipPermissionsFields: FC< + TrustRelationshipPermissionsFieldsProps +> = ({ + isAdmin, + onAddRole, + onIsAdminChange, + onRemoveRole, + organisationId, + roles, +}) => { + const [showRoles, setShowRoles] = useState(false) + + return ( + <> + + + + + + + {!isAdmin && ( + <> + + + {roles.map((role) => ( + onRemoveRole(role.id)} + className='chip' + > + {role.name} + + + + + ))} + + + +
+ role.id)} + onAdd={(role) => onAddRole(role as unknown as SelectedRole)} + onRemove={onRemoveRole} + isOpen={showRoles} + onToggle={() => setShowRoles(!showRoles)} + /> +
+
+ + )} + + ) +} + +export default TrustRelationshipPermissionsFields diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/index.ts new file mode 100644 index 000000000000..97e0d19bc420 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/index.ts @@ -0,0 +1,2 @@ +export { default } from './TrustRelationshipPermissionsFields' +export type { SelectedRole } from './TrustRelationshipPermissionsFields' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/TrustRelationships.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/TrustRelationships.tsx new file mode 100644 index 000000000000..18dfb7674d96 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/TrustRelationships.tsx @@ -0,0 +1,194 @@ +import React, { FC } from 'react' +import moment from 'moment' +import Button from 'components/base/forms/Button' +import EmptyState from 'components/EmptyState' +import Icon from 'components/icons/Icon' +import PageTitle from 'components/PageTitle' +import PanelSearch from 'components/PanelSearch' +import Switch from 'components/Switch' +import GithubTrustRelationshipForm from 'components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm' +import { trustRelationshipErrorMessage } from 'components/pages/organisation-settings/tabs/trust-relationships/errors' +import NewTrustRelationshipModal from 'components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal' +import TrustRelationshipModal from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal' +import { isGithubFormEditable } from 'components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet' +import { providerForIssuer } from 'components/pages/organisation-settings/tabs/trust-relationships/providers' +import { TrustRelationship } from 'common/types/responses' +import { + useDeleteTrustRelationshipMutation, + useGetTrustRelationshipsQuery, +} from 'common/services/useTrustRelationship' + +const IssuerCell: FC<{ issuer: string }> = ({ issuer }) => { + const provider = providerForIssuer(issuer) + if (!provider) { + return {issuer} + } + return ( + {provider.icon}}> + {`${issuer}`} + + ) +} + +type TrustRelationshipsProps = { + organisationId: number +} + +const TrustRelationships: FC = ({ + organisationId, +}) => { + const { data, isLoading } = useGetTrustRelationshipsQuery( + { organisation_id: organisationId }, + { skip: !organisationId }, + ) + const [deleteTrustRelationship] = useDeleteTrustRelationshipMutation() + + const editTrustRelationship = (trustRelationship: TrustRelationship) => + openModal( + `${trustRelationship.name} trust relationship`, + isGithubFormEditable(trustRelationship) ? ( + existing.audience, + )} + /> + ) : ( + + ), + 'p-0 side-modal', + ) + + const addTrustRelationship = () => + openModal( + 'New trust relationship', + trustRelationship.audience, + )} + />, + 'p-0 side-modal', + ) + + const remove = (trustRelationship: TrustRelationship) => { + openConfirm({ + body: ( +
+ Deleting the {trustRelationship.name} trust + relationship immediately stops token exchange for it, and any + outstanding access tokens stop working. +
+ ), + destructive: true, + onYes: () => { + deleteTrustRelationship({ + id: trustRelationship.id, + organisation_id: organisationId, + }) + .unwrap() + .then(() => toast('Trust relationship deleted')) + .catch((error) => + toast( + trustRelationshipErrorMessage( + error, + 'Could not delete trust relationship', + ), + 'danger', + ), + ) + }, + title: 'Delete trust relationship', + yesText: 'Delete', + }) + } + + return ( +
+ Add trust relationship + } + > + Let a trusted OIDC identity provider exchange its tokens for short-lived + Flagsmith access tokens. + + {!isLoading && !data?.results?.length && ( + + )} + {!!data?.results?.length && ( + + Name + Issuer + Audience +
+ Is admin +
+
+ Remove +
+ + } + renderRow={(trustRelationship: TrustRelationship) => ( + editTrustRelationship(trustRelationship)} + data-test={`trust-relationship-${trustRelationship.id}`} + > + +
+ {trustRelationship.name} +
+
+ Created{' '} + {moment(trustRelationship.created_at).format('Do MMM YYYY')} +
+
+ + + + + {trustRelationship.audience} + +
e.stopPropagation()} + > + +
+
+ +
+
+ )} + /> + )} +
+ ) +} + +export default TrustRelationships diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/index.ts new file mode 100644 index 000000000000..7469d6036418 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/index.ts @@ -0,0 +1 @@ +export { default } from './TrustRelationships' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/WorkflowSetupSnippet.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/WorkflowSetupSnippet.tsx new file mode 100644 index 000000000000..add9df960618 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/WorkflowSetupSnippet.tsx @@ -0,0 +1,85 @@ +import React, { FC, useMemo } from 'react' +import CodeCard from 'components/pages/onboarding/OnboardingConnectPanel/CodeCard' +import Icon from 'components/icons/Icon' +import { TrustRelationship } from 'common/types/responses' + +export const GITHUB_ISSUER = 'https://token.actions.githubusercontent.com' + +// Claims the GitHub form can round-trip; anything else edits as freeform. +const GITHUB_FORM_CLAIMS = ['repository', 'repository_id', 'environment'] + +export const isGithubFormEditable = ( + trustRelationship: TrustRelationship, +): boolean => + trustRelationship.issuer === GITHUB_ISSUER && + trustRelationship.claim_rules.every( + (rule) => + GITHUB_FORM_CLAIMS.includes(rule.claim) && rule.values.length === 1, + ) && + trustRelationship.claim_rules.some( + (rule) => rule.claim === 'repository' || rule.claim === 'repository_id', + ) + +const GITHUB_OWNER_AUDIENCE_REGEX = /^https:\/\/github\.com\/[^/]+$/ + +export const isDefaultGithubAudience = (audience: string): boolean => + GITHUB_OWNER_AUDIENCE_REGEX.test(audience) + +type WorkflowSetupSnippetProps = { + audience: string + environment?: string +} + +const WorkflowSetupSnippet: FC = ({ + audience, + environment, +}) => { + const isDefaultAudience = isDefaultGithubAudience(audience) + const code = useMemo(() => { + const lines = ['jobs:', ' flagsmith:', ' runs-on: ubuntu-latest'] + if (environment) { + lines.push(` environment: ${environment}`) + } + lines.push( + ' permissions:', + ' id-token: write', + ' steps:', + ' - uses: Flagsmith/setup-cli@v1', + ) + if (!isDefaultAudience) { + lines.push(' with:', ` audience: ${audience}`) + } + return lines.join('\n') + }, [environment, isDefaultAudience, audience]) + + const header = Workflow setup + return ( +
+ + {header} + + + } + > + { + 'The default audience for this owner is already in use, so the workflow must request this audience explicitly.' + } + + ) + } + /> +
+ ) +} + +export default WorkflowSetupSnippet diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/index.ts new file mode 100644 index 000000000000..a3a4266cf307 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/index.ts @@ -0,0 +1,6 @@ +export { + default, + GITHUB_ISSUER, + isGithubFormEditable, + isDefaultGithubAudience, +} from './WorkflowSetupSnippet' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/errors.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/errors.ts new file mode 100644 index 000000000000..6e88715ee6da --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/errors.ts @@ -0,0 +1,18 @@ +// Extracts the first human-readable message from a DRF error body, which +// arrives as {detail: "..."}, {field: ["..."]}, or {non_field_errors: ["..."]}. +export const trustRelationshipErrorMessage = ( + error: unknown, + fallback: string, +): string => { + const data = (error as { data?: unknown })?.data + if (data && typeof data === 'object') { + const first = Object.values(data as Record)[0] + if (typeof first === 'string') { + return first + } + if (Array.isArray(first) && typeof first[0] === 'string') { + return first[0] + } + } + return fallback +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/__tests__/useTrustRelationshipRoles.test.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/__tests__/useTrustRelationshipRoles.test.ts new file mode 100644 index 000000000000..eba3316e0722 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/__tests__/useTrustRelationshipRoles.test.ts @@ -0,0 +1,177 @@ +import { createRoleHandlers } from 'components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles' +import { SelectedRole } from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' +import { TrustRelationship } from 'common/types/responses' +import { createRoleMasterApiKey } from 'common/services/useRoleMasterApiKey' +import { deleteMasterAPIKeyWithMasterAPIKeyRoles } from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' + +jest.mock('common/store', () => ({ getStore: () => ({}) })) +jest.mock('common/services/useRoleMasterApiKey', () => ({ + createRoleMasterApiKey: jest.fn(), +})) +jest.mock('common/services/useMasterAPIKeyWithMasterAPIKeyRole', () => ({ + deleteMasterAPIKeyWithMasterAPIKeyRoles: jest.fn(), + getRolesMasterAPIKeyWithMasterAPIKeyRoles: jest.fn(), +})) + +const mockCreate = createRoleMasterApiKey as jest.Mock +const mockDelete = deleteMasterAPIKeyWithMasterAPIKeyRoles as jest.Mock + +const trustRelationship = { + audience: 'https://github.com/Flagsmith', + claim_rules: [], + created_at: '', + created_by: null, + id: 1, + is_admin: false, + issuer: 'https://token.actions.githubusercontent.com', + master_api_key_id: 'key-id', + master_api_key_prefix: 'prefix', + name: 'GitHub Actions', +} as TrustRelationship + +const role: SelectedRole = { id: 7, name: 'Deployer' } + +const trackRoles = () => { + let state: SelectedRole[] = [role] + const setRoles = jest.fn( + ( + update: SelectedRole[] | ((previous: SelectedRole[]) => SelectedRole[]), + ) => { + state = typeof update === 'function' ? update(state) : update + }, + ) + return { getState: () => state, setRoles } +} + +beforeEach(() => { + ;(global as { toast?: unknown }).toast = jest.fn() +}) + +describe('addRole', () => { + it('does not update local state when the API call fails', async () => { + // Given + mockCreate.mockResolvedValue({ error: { status: 403 } }) + const { getState, setRoles } = trackRoles() + const handlers = createRoleHandlers({ + organisationId: 1, + setRoles, + trustRelationship, + }) + + // When + handlers.addRole({ id: 8, name: 'Reader' }) + await mockCreate.mock.results[0].value + + // Then + expect(setRoles).not.toHaveBeenCalled() + expect(getState()).toEqual([role]) + expect(toast).toHaveBeenCalledWith('Could not assign role', 'danger') + }) + + it('updates local state when the API call succeeds', async () => { + // Given + mockCreate.mockResolvedValue({ data: {} }) + const { getState, setRoles } = trackRoles() + const handlers = createRoleHandlers({ + organisationId: 1, + setRoles, + trustRelationship, + }) + + // When + handlers.addRole({ id: 8, name: 'Reader' }) + await mockCreate.mock.results[0].value + + // Then + expect(getState()).toEqual([role, { id: 8, name: 'Reader' }]) + expect(toast).toHaveBeenCalledWith('Role assigned') + }) + + it('updates local state without an API call in create mode', () => { + // Given + const { getState, setRoles } = trackRoles() + const handlers = createRoleHandlers({ organisationId: 1, setRoles }) + + // When + handlers.addRole({ id: 8, name: 'Reader' }) + + // Then + expect(mockCreate).not.toHaveBeenCalled() + expect(getState()).toEqual([role, { id: 8, name: 'Reader' }]) + }) +}) + +describe('removeRole', () => { + it('does not update local state when the API call fails', async () => { + // Given + mockDelete.mockResolvedValue({ error: { status: 403 } }) + const { getState, setRoles } = trackRoles() + const handlers = createRoleHandlers({ + organisationId: 1, + setRoles, + trustRelationship, + }) + + // When + handlers.removeRole(role.id) + await mockDelete.mock.results[0].value + + // Then + expect(setRoles).not.toHaveBeenCalled() + expect(getState()).toEqual([role]) + expect(toast).toHaveBeenCalledWith('Could not remove role', 'danger') + }) + + it('updates local state when the API call succeeds', async () => { + // Given + mockDelete.mockResolvedValue({ data: {} }) + const { getState, setRoles } = trackRoles() + const handlers = createRoleHandlers({ + organisationId: 1, + setRoles, + trustRelationship, + }) + + // When + handlers.removeRole(role.id) + await mockDelete.mock.results[0].value + + // Then + expect(getState()).toEqual([]) + expect(toast).toHaveBeenCalledWith('Role removed') + }) +}) + +describe('assignRoles', () => { + it('resolves false when any assignment fails', async () => { + // Given + mockCreate + .mockResolvedValueOnce({ data: {} }) + .mockResolvedValueOnce({ error: { status: 404 } }) + const { setRoles } = trackRoles() + const handlers = createRoleHandlers({ organisationId: 1, setRoles }) + + // When + const allAssigned = await handlers.assignRoles('key-id', [ + role, + { id: 8, name: 'Reader' }, + ]) + + // Then + expect(allAssigned).toBe(false) + expect(mockCreate).toHaveBeenCalledTimes(2) + }) + + it('resolves true when every assignment succeeds', async () => { + // Given + mockCreate.mockResolvedValue({ data: {} }) + const { setRoles } = trackRoles() + const handlers = createRoleHandlers({ organisationId: 1, setRoles }) + + // When + const allAssigned = await handlers.assignRoles('key-id', [role]) + + // Then + expect(allAssigned).toBe(true) + }) +}) diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles.ts new file mode 100644 index 000000000000..44409f179acd --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles.ts @@ -0,0 +1,115 @@ +import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react' +import { getStore } from 'common/store' +import { TrustRelationship } from 'common/types/responses' +import { createRoleMasterApiKey } from 'common/services/useRoleMasterApiKey' +import { + deleteMasterAPIKeyWithMasterAPIKeyRoles, + getRolesMasterAPIKeyWithMasterAPIKeyRoles, +} from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' +import { SelectedRole } from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' + +type RoleHandlerContext = { + organisationId: number + trustRelationship?: TrustRelationship + setRoles: Dispatch> +} + +// The behaviour shared by both trust relationship forms, kept free of React +// so it can be unit tested. In edit mode, changes apply to the backing key +// immediately and local state only follows confirmed API results; in create +// mode, roles accumulate locally and are assigned after creation via +// `assignRoles`. +export const createRoleHandlers = ({ + organisationId, + setRoles, + trustRelationship, +}: RoleHandlerContext) => ({ + addRole: (role: SelectedRole): void => { + if (!trustRelationship) { + // Roles are assigned after the trust relationship is created. + setRoles((selected) => [...selected, { id: role.id, name: role.name }]) + return + } + createRoleMasterApiKey(getStore(), { + body: { master_api_key: trustRelationship.master_api_key_id }, + org_id: organisationId, + role_id: role.id, + }).then((res: { error?: unknown }) => { + if (res.error) { + toast('Could not assign role', 'danger') + return + } + setRoles((selected) => [...selected, { id: role.id, name: role.name }]) + toast('Role assigned') + }) + }, + assignRoles: async ( + masterApiKeyId: string, + roles: SelectedRole[], + ): Promise => { + // Create mode: assign the accumulated roles to the freshly created + // relationship's backing key. Resolves whether every assignment + // succeeded. + const results: { error?: unknown }[] = await Promise.all( + roles.map((role) => + createRoleMasterApiKey(getStore(), { + body: { master_api_key: masterApiKeyId }, + org_id: organisationId, + role_id: role.id, + }), + ), + ) + return results.every((res) => !res.error) + }, + removeRole: (roleId: number): void => { + if (!trustRelationship) { + setRoles((selected) => selected.filter((role) => role.id !== roleId)) + return + } + deleteMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { + org_id: organisationId, + prefix: trustRelationship.master_api_key_prefix, + role_id: roleId, + }).then((res: { error?: unknown }) => { + if (res.error) { + toast('Could not remove role', 'danger') + return + } + setRoles((selected) => selected.filter((role) => role.id !== roleId)) + toast('Role removed') + }) + }, +}) + +export default function useTrustRelationshipRoles( + organisationId: number, + trustRelationship?: TrustRelationship, +) { + const [roles, setRoles] = useState([]) + + useEffect(() => { + if (trustRelationship) { + getRolesMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { + org_id: organisationId, + prefix: trustRelationship.master_api_key_prefix, + }).then((res: { data?: { results: SelectedRole[] } }) => { + setRoles(res.data?.results || []) + }) + } + }, [organisationId, trustRelationship]) + + const handlers = useMemo( + () => createRoleHandlers({ organisationId, setRoles, trustRelationship }), + [organisationId, trustRelationship], + ) + + return { + addRole: handlers.addRole, + assignRoles: (masterApiKeyId: string) => + handlers.assignRoles(masterApiKeyId, roles), + // Turning admin on detaches roles server-side; mirror that locally. + clearRoles: () => setRoles([]), + removeRole: handlers.removeRole, + roles, + } +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.ts new file mode 100644 index 000000000000..7469d6036418 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.ts @@ -0,0 +1 @@ +export { default } from './TrustRelationships' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/providers.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/providers.tsx new file mode 100644 index 000000000000..2019884a6698 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/providers.tsx @@ -0,0 +1,24 @@ +import React, { ReactNode } from 'react' +import { GithubIcon } from 'components/icons/GithubIcon' +import { GITHUB_ISSUER } from './WorkflowSetupSnippet' + +// Known OIDC providers, keyed by issuer. Add an entry here to give a future +// preset (GitLab CI, Kubernetes, ...) its own badge in the list view. +export type TrustRelationshipProvider = { + issuer: string + label: string + icon: ReactNode +} + +export const TRUST_RELATIONSHIP_PROVIDERS: TrustRelationshipProvider[] = [ + { + icon: , + issuer: GITHUB_ISSUER, + label: 'GitHub Actions', + }, +] + +export const providerForIssuer = ( + issuer: string, +): TrustRelationshipProvider | undefined => + TRUST_RELATIONSHIP_PROVIDERS.find((provider) => provider.issuer === issuer)