Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"2fa-enforcement-disabled": "2FA enforcement has been disabled.",
"2fa-enforcement-enable-anyway": "Enable Anyway",
"2fa-enforcement-enabled": "2FA enforcement has been enabled for this organization.",
"2fa-enforcement-self-2fa-required": "Enable two-factor authentication on your account before applying organization-wide 2FA enforcement.",
"2fa-enforcement-title": "Require 2FA for All Members",
"2fa-enforcement-warning-description": "Some organization members do not have 2FA enabled. Once you enable enforcement, they will be locked out of the organization until they enable 2FA.",
"2fa-enforcement-warning-title": "Members Will Be Impacted",
Expand Down
24 changes: 20 additions & 4 deletions src/pages/settings/organization/Security.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { FunctionsHttpError } from '@supabase/supabase-js'
import { computedAsync } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
Expand Down Expand Up @@ -384,12 +385,27 @@ async function save2faEnforcement(value: boolean) {
isSaving.value = true

try {
const { error } = await supabase
.from('orgs')
.update({ enforcing_2fa: value })
.eq('id', currentOrganization.value.gid)
const { error } = await supabase.functions.invoke('organization', {
method: 'PUT',
body: {
Comment on lines +388 to +390
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use JWT-compatible endpoint for org 2FA toggle

This UI path now calls supabase.functions.invoke('organization', { method: 'PUT' ... }), but that endpoint is guarded by middlewareKey in supabase/functions/_backend/public/organization/index.ts (API-key auth), not JWT auth; dashboard sessions normally send a JWT and no capgkey, so the request is rejected as invalid_apikey before the 2FA prerequisite logic runs. In practice this makes organization 2FA enforcement changes fail for signed-in web users unless they manually provide an API key header.

Useful? React with 👍 / 👎.

orgId: currentOrganization.value.gid,
enforcing_2fa: value,
},
})

if (error) {
if (error instanceof FunctionsHttpError && error.context instanceof Response) {
try {
const payload = await error.context.clone().json<{ error?: string }>()
if (payload.error === 'requires_2fa_to_enforce_2fa') {
toast.error(t('2fa-enforcement-self-2fa-required'))
return
}
}
catch {
console.warn('Could not parse org security function error payload')
}
}
console.error('Error updating 2FA enforcement:', error)
toast.error(t('error-saving-settings'))
return
Expand Down
26 changes: 25 additions & 1 deletion supabase/functions/_backend/public/organization/put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { z } from 'zod/mini'
import { quickError, simpleError } from '../../utils/hono.ts'
import { checkPermission } from '../../utils/rbac.ts'
import { createSignedImageUrl, normalizeImagePath } from '../../utils/storage.ts'
import { apikeyHasOrgRightWithPolicy, supabaseApikey } from '../../utils/supabase.ts'
import { apikeyHasOrgRightWithPolicy, supabaseAdmin, supabaseApikey } from '../../utils/supabase.ts'

const bodySchema = z.object({
orgId: z.string(),
Expand All @@ -15,6 +15,7 @@ const bodySchema = z.object({
require_apikey_expiration: z.optional(z.boolean()),
max_apikey_expiration_days: z.optional(z.nullable(z.number())),
enforce_hashed_api_keys: z.optional(z.boolean()),
enforcing_2fa: z.optional(z.boolean()),
})

function parseBody(bodyRaw: unknown) {
Expand Down Expand Up @@ -68,9 +69,23 @@ function buildUpdateFields(body: z.infer<typeof bodySchema>) {
updateFields.max_apikey_expiration_days = body.max_apikey_expiration_days
if (body.enforce_hashed_api_keys !== undefined)
updateFields.enforce_hashed_api_keys = body.enforce_hashed_api_keys
if (body.enforcing_2fa !== undefined)
updateFields.enforcing_2fa = body.enforcing_2fa
return updateFields
}

async function enforceSelf2faRequirement(authUserId: string, c: Context<MiddlewareKeyVariables>) {
const { data: has2faEnabled, error } = await supabaseAdmin(c)
.rpc('has_2fa_enabled', { user_id: authUserId })

if (error) {
throw quickError(500, 'cannot_check_2fa', 'Cannot verify your 2FA status', { error: error.message })
}
if (!has2faEnabled) {
throw simpleError('requires_2fa_to_enforce_2fa', 'You must enable 2FA before enforcing it for your organization')
}
}

async function updateOrg(
supabase: ReturnType<typeof supabaseApikey>,
orgId: string,
Expand All @@ -93,9 +108,18 @@ async function updateOrg(
export async function put(c: Context<MiddlewareKeyVariables>, bodyRaw: any, apikey: Database['public']['Tables']['apikeys']['Row']): Promise<Response> {
const body = parseBody(bodyRaw)
const supabase = supabaseApikey(c, apikey.key)
const authUserId = c.get('auth')?.userId

// Auth context is already set by middlewareKey
await ensureOrgAccess(c, apikey, body.orgId, supabase)

if (body.enforcing_2fa && authUserId) {
await enforceSelf2faRequirement(authUserId, c)
}
else if (body.enforcing_2fa) {
throw simpleError('cannot_access_organization', 'You can\\'t access this organization', { orgId: body.orgId })
}

validateMaxExpirationDays(body.max_apikey_expiration_days)
const updateFields = buildUpdateFields(body)
const dataOrg = await updateOrg(supabase, body.orgId, updateFields)
Expand Down
Loading