From bd55ad23a697be004a0127765351e4ecfca4297f Mon Sep 17 00:00:00 2001 From: Stephen Morgan Date: Tue, 21 Apr 2026 16:38:03 +1200 Subject: [PATCH] feat: iso27001 certificate (#44963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit: Can be merged, mgmt api deployed Dashboard addition to frontend for access to the ISO 27001 certificate. View for Team customers: image Resolves SEC-799 ## Summary by CodeRabbit * **New Features** * ISO 27001 certificate added to Documents with a Download action, confirmation modal, new-tab open on success, and error toast on failure. * Users without billing permission see a no-permission view; users missing entitlement see an “Upgrade to Team” prompt. * **Refactor** * Upgrade-to-Team flows for SOC2 and related upgrade UI standardized to use the shared upgrade component. --------- Co-authored-by: Joshen Lim --- .../Organization/Documents/Documents.tsx | 7 + .../Organization/Documents/ISO27001.tsx | 145 ++++++++++++++++++ .../Organization/Documents/SOC2.tsx | 13 +- .../ui/RequestUpgradeToBillingOwners.tsx | 10 +- .../components/ui/UpgradePlanButton.tsx | 7 +- apps/studio/data/documents/document-query.ts | 18 ++- packages/api-types/types/api.d.ts | 1 + packages/api-types/types/platform.d.ts | 61 ++++++++ packages/common/telemetry-constants.ts | 2 +- 9 files changed, 248 insertions(+), 16 deletions(-) create mode 100644 apps/studio/components/interfaces/Organization/Documents/ISO27001.tsx diff --git a/apps/studio/components/interfaces/Organization/Documents/Documents.tsx b/apps/studio/components/interfaces/Organization/Documents/Documents.tsx index 1a1859b75a3..2b94333de48 100644 --- a/apps/studio/components/interfaces/Organization/Documents/Documents.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/Documents.tsx @@ -3,6 +3,7 @@ import { Fragment } from 'react' import { CustomDocument } from './CustomDocument' import { DPA } from './DPA' import { HIPAA } from './HIPAA' +import { ISO27001 } from './ISO27001' import { SecurityQuestionnaire } from './SecurityQuestionnaire' import { SOC2 } from './SOC2' import { TIA } from './TIA' @@ -50,6 +51,12 @@ export const Documents = () => { + + + + + + diff --git a/apps/studio/components/interfaces/Organization/Documents/ISO27001.tsx b/apps/studio/components/interfaces/Organization/Documents/ISO27001.tsx new file mode 100644 index 00000000000..f2c7a0051d5 --- /dev/null +++ b/apps/studio/components/interfaces/Organization/Documents/ISO27001.tsx @@ -0,0 +1,145 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Download } from 'lucide-react' +import { useState } from 'react' +import { toast } from 'sonner' +import { Button } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionDetail, +} from '@/components/layouts/Scaffold' +import NoPermission from '@/components/ui/NoPermission' +import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' +import { getDocument } from '@/data/documents/document-query' +import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' + +export const ISO27001 = () => { + const { data: organization } = useSelectedOrganizationQuery() + const slug = organization?.slug + + const { mutate: sendEvent } = useSendEventMutation() + const { can: canReadSubscriptions, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( + PermissionAction.BILLING_READ, + 'stripe.subscriptions' + ) + const { hasAccess: hasAccessToISO27001, isLoading: isLoadingEntitlement } = useCheckEntitlements( + 'security.iso27001_certificate' + ) + + const [isOpen, setIsOpen] = useState(false) + + const fetchISO27001 = async (orgSlug: string) => { + try { + const link = await getDocument({ orgSlug, docType: 'iso27001-certificate' }) + if (link?.fileUrl) window.open(link.fileUrl, '_blank') + setIsOpen(false) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error occurred' + toast.error(`Failed to download ISO 27001 certificate: ${message}`) + } + } + + const handleDownloadClick = () => { + if (!slug) return + + sendEvent({ + action: 'document_view_button_clicked', + properties: { documentName: 'ISO27001' }, + groups: { organization: slug }, + }) + setIsOpen(true) + } + + return ( + + +

ISO 27001

+
+

+ Organizations on Team Plan or above have access to our most recent ISO 27001 + certificate. +

+
+
+ + {isLoadingPermissions || isLoadingEntitlement ? ( +
+ +
+ ) : !canReadSubscriptions ? ( + + ) : !hasAccessToISO27001 ? ( +
+ +
+ ) : ( +
+ +
+ )} + setIsOpen(false)} + onConfirm={() => { + if (slug) fetchISO27001(slug) + }} + > +
    +
  1. The information that you are about to access is confidential.
  2. +
  3. + Your access to our ISO 27001 materials is governed by confidentiality obligations + contained in the agreement between Supabase, Inc ("Supabase", "we", "our" or "us") and + the Supabase customer that has authorized you to access our platform to obtain this + information (our "Customer"). +
  4. +
  5. + You must ensure that you treat the information in our ISO 27001 materials in + accordance with those confidentiality obligations, as communicated to you by the + Customer. +
  6. +
  7. + By clicking "I agree" below or otherwise accessing our ISO 27001 materials, you: +
      +
    1. acknowledge that you have read and understood this Confidentiality Notice;
    2. +
    3. + confirm that you have been authorized by the Customer to access this information, + and your use of our ISO 27001 materials is subject to the confidentiality + obligations owed by the Customer to us. +
    4. +
    +
  8. +
  9. + This Confidentiality Notice does not substitute or supersede any agreement between us + and the Customer, or any internal rules or policies that the Customer requires you to + comply with in your access to and use of confidential information. However, your + failure to comply with this Confidentiality Notice may be used to determine whether + the Customer has complied with its confidentiality obligations to us. +
  10. +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx b/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx index 17867dfd75b..e02f6b31420 100644 --- a/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/SOC2.tsx @@ -1,6 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Download } from 'lucide-react' -import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' import { Button } from 'ui' @@ -13,6 +12,7 @@ import { ScaffoldSectionDetail, } from '@/components/layouts/Scaffold' import NoPermission from '@/components/ui/NoPermission' +import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton' import { getDocument } from '@/data/documents/document-query' import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' @@ -74,11 +74,12 @@ export const SOC2 = () => { ) : !hasAccessToSoc2Report ? (
- +
) : (
diff --git a/apps/studio/components/ui/RequestUpgradeToBillingOwners.tsx b/apps/studio/components/ui/RequestUpgradeToBillingOwners.tsx index 08c36fbd938..1bdb6ad2023 100644 --- a/apps/studio/components/ui/RequestUpgradeToBillingOwners.tsx +++ b/apps/studio/components/ui/RequestUpgradeToBillingOwners.tsx @@ -48,6 +48,7 @@ interface RequestUpgradeToBillingOwnersProps { /** Used in the default message template, e.g: "Upgrade to ..." */ featureProposition?: string className?: string + type?: 'primary' | 'default' } export const RequestUpgradeToBillingOwners = ({ @@ -57,6 +58,7 @@ export const RequestUpgradeToBillingOwners = ({ featureProposition, children, className, + type = 'primary', }: PropsWithChildren) => { const [open, setOpen] = useState(false) const track = useTrack() @@ -121,10 +123,8 @@ export const RequestUpgradeToBillingOwners = ({ const defaultValues = { note: !!addon - ? addon === 'spendCap' - ? `We'd like to ${isFreePlan ? 'upgrade to Pro and ' : ''}${action} ${target} so that we can ${featureProposition}` - : `We'd like to ${isFreePlan ? 'upgrade to Pro and ' : ''}${action} ${target} so that we can ${featureProposition}` - : `We'd like to upgrade to the ${plan} plan ${!!featureProposition ? ` to ${featureProposition} ` : ''}${target}`, + ? `We'd like to ${isFreePlan ? 'upgrade to Pro and ' : ''}${action} ${target} so that we can ${featureProposition}` + : `We'd like to upgrade to the ${plan} plan ${!!featureProposition ? `to ${featureProposition} ` : ''}${target}`, } const form = useForm>({ resolver: zodResolver(FormSchema), @@ -162,7 +162,7 @@ export const RequestUpgradeToBillingOwners = ({ return ( - diff --git a/apps/studio/components/ui/UpgradePlanButton.tsx b/apps/studio/components/ui/UpgradePlanButton.tsx index 1366da87a9c..cf11df6a4e3 100644 --- a/apps/studio/components/ui/UpgradePlanButton.tsx +++ b/apps/studio/components/ui/UpgradePlanButton.tsx @@ -35,7 +35,7 @@ interface UpgradePlanButtonProps { */ export const UpgradePlanButton = ({ source, - variant = 'primary', + variant: type = 'primary', plan = 'Pro', addon, featureProposition, @@ -97,6 +97,7 @@ export const UpgradePlanButton = ({ addon={addon} featureProposition={featureProposition} className={className} + type={type} > {children} @@ -107,7 +108,7 @@ export const UpgradePlanButton = ({ return ( + ) diff --git a/apps/studio/data/documents/document-query.ts b/apps/studio/data/documents/document-query.ts index cb01257db5d..b98b8cd5765 100644 --- a/apps/studio/data/documents/document-query.ts +++ b/apps/studio/data/documents/document-query.ts @@ -4,7 +4,10 @@ import { documentKeys } from './keys' import { get, handleError } from '@/data/fetchers' import type { ResponseError, UseCustomQueryOptions } from '@/types' -export type DocType = 'standard-security-questionnaire' | 'soc2-type-2-report' +export type DocType = + | 'standard-security-questionnaire' + | 'soc2-type-2-report' + | 'iso27001-certificate' export type DocumentVariables = { orgSlug?: string @@ -39,6 +42,19 @@ export async function getDocument({ orgSlug, docType }: DocumentVariables, signa return data as { fileUrl: string } } + + if (docType === 'iso27001-certificate') { + const { data, error } = await get( + `/platform/organizations/{slug}/documents/iso27001-certificate`, + { + params: { path: { slug: orgSlug } }, + signal, + } + ) + if (error) throw error + + return data as { fileUrl: string } + } } export type DocumentData = Awaited> diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index a8221f6af38..7ea9fdb448c 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -4896,6 +4896,7 @@ export interface components { | 'security.audit_logs_days' | 'security.questionnaire' | 'security.soc2_report' + | 'security.iso27001_certificate' | 'security.private_link' | 'security.enforce_mfa' | 'log.retention_days' diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 49825a991c5..e9be01f90f4 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -1372,6 +1372,23 @@ export interface paths { patch?: never trace?: never } + '/platform/organizations/{slug}/documents/iso27001-certificate': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get ISO 27001 certificate URL */ + get: operations['OrgDocumentsController_getIso27001CertificateUrl'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/platform/organizations/{slug}/documents/soc2-type-2-report': { parameters: { query?: never @@ -7263,6 +7280,7 @@ export interface components { | 'security.audit_logs_days' | 'security.questionnaire' | 'security.soc2_report' + | 'security.iso27001_certificate' | 'security.private_link' | 'security.enforce_mfa' | 'log.retention_days' @@ -15414,6 +15432,49 @@ export interface operations { } } } + OrgDocumentsController_getIso27001CertificateUrl: { + parameters: { + query?: never + header?: never + path: { + /** @description Organization slug */ + slug: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['OrgDocumentUrlResponse'] + } + } + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } OrgDocumentsController_getSoc2Type2ReportUrl: { parameters: { query?: never diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 7d677fd85b8..31d33ff9ce1 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2016,7 +2016,7 @@ export interface DocumentViewButtonClickedEvent { /** * The name of the document being viewed, e.g. TIA, SOC2, Standard Security Questionnaire */ - documentName: 'TIA' | 'SOC2' | 'Standard Security Questionnaire' + documentName: 'TIA' | 'SOC2' | 'ISO27001' | 'Standard Security Questionnaire' } groups: Omit }