Skip to content
Open
52 changes: 28 additions & 24 deletions src/pages/settings/organization/Security.vue
Original file line number Diff line number Diff line change
Expand Up @@ -422,12 +422,8 @@ async function save2faEnforcement(value: boolean) {
isSaving.value = true

try {
const { error } = await supabase.functions.invoke('organization', {
method: 'PUT',
body: {
orgId: currentOrganization.value.gid,
enforcing_2fa: value,
},
const { error } = await updateOrganizationSettings({
enforcing_2fa: value,
})

if (error) {
Expand Down Expand Up @@ -460,6 +456,19 @@ async function save2faEnforcement(value: boolean) {
}
}

async function updateOrganizationSettings(body: Record<string, unknown>) {
if (!currentOrganization.value?.gid)
return { error: new Error('No organization selected') }

return await supabase.functions.invoke('organization', {
method: 'PUT',
body: {
orgId: currentOrganization.value.gid,
...body,
},
})
}

async function toggleEnforceHashedApiKeys() {
if (!currentOrganization.value || !hasOrgPerm.value) {
toast.error(t('no-permission'))
Expand All @@ -475,10 +484,9 @@ async function toggleEnforceHashedApiKeys() {
isSaving.value = true

try {
const { error } = await supabase
.from('orgs')
.update({ enforce_hashed_api_keys: newValue })
.eq('id', currentOrganization.value.gid)
const { error } = await updateOrganizationSettings({
enforce_hashed_api_keys: newValue,
})

if (error) {
console.error('Failed to update enforce_hashed_api_keys:', error)
Expand All @@ -488,6 +496,7 @@ async function toggleEnforceHashedApiKeys() {
return
}

await organizationStore.fetchOrganizations()
toast.success(newValue ? t('hashed-api-keys-enforcement-enabled') : t('hashed-api-keys-enforcement-disabled'))
}
catch (error) {
Expand Down Expand Up @@ -611,13 +620,10 @@ async function saveEncryptedBundlesEnforcement(enable: boolean, keyFingerprint:
}

// Update the org settings
const { error } = await supabase
.from('orgs')
.update({
enforce_encrypted_bundles: enable,
required_encryption_key: keyFingerprint || null,
})
.eq('id', currentOrganization.value.gid)
const { error } = await updateOrganizationSettings({
enforce_encrypted_bundles: enable,
required_encryption_key: keyFingerprint || null,
})

if (error) {
console.error('Failed to update enforce_encrypted_bundles:', error)
Expand All @@ -627,6 +633,7 @@ async function saveEncryptedBundlesEnforcement(enable: boolean, keyFingerprint:
return
}

await organizationStore.fetchOrganizations()
if (enable) {
const deletedCount = nonCompliantBundleCounts.value?.total_non_compliant ?? 0
if (deletedCount > 0) {
Expand Down Expand Up @@ -869,13 +876,10 @@ async function saveApikeyPolicy() {

isSaving.value = true

const { error } = await supabase
.from('orgs')
.update({
require_apikey_expiration: requireApikeyExpiration.value,
max_apikey_expiration_days: maxApikeyExpirationDays.value,
})
.eq('id', currentOrganization.value.gid)
const { error } = await updateOrganizationSettings({
require_apikey_expiration: requireApikeyExpiration.value,
max_apikey_expiration_days: maxApikeyExpirationDays.value,
})

isSaving.value = false

Expand Down
22 changes: 22 additions & 0 deletions supabase/functions/_backend/public/organization/put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ 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()),
enforce_encrypted_bundles: z.optional(z.boolean()),
required_encryption_key: z.optional(z.nullable(z.string())),
enforcing_2fa: z.optional(z.boolean()),
})

Expand Down Expand Up @@ -61,6 +63,22 @@ function validateMaxExpirationDays(maxDays?: number | null) {
}
}

function normalizeRequiredEncryptionKey(requiredEncryptionKey?: string | null) {
const normalized = requiredEncryptionKey?.trim() ?? null
return normalized === '' ? null : normalized
}

function validateRequiredEncryptionKey(requiredEncryptionKey?: string | null) {
const normalized = normalizeRequiredEncryptionKey(requiredEncryptionKey)
if (normalized === null) {
return normalized
}
if (normalized.length !== 21) {
throw simpleError('invalid_required_encryption_key', 'Required encryption key must be exactly 21 characters')
}
return normalized
}

function buildUpdateFields(body: z.infer<typeof bodySchema>) {
const updateFields: Partial<Database['public']['Tables']['orgs']['Update']> = {}
if (body.name !== undefined)
Expand All @@ -77,6 +95,10 @@ 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.enforce_encrypted_bundles !== undefined)
updateFields.enforce_encrypted_bundles = body.enforce_encrypted_bundles
if (body.required_encryption_key !== undefined)
updateFields.required_encryption_key = validateRequiredEncryptionKey(body.required_encryption_key)
if (body.enforcing_2fa !== undefined)
updateFields.enforcing_2fa = body.enforcing_2fa
return updateFields
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ALTER TABLE public.orgs
DROP CONSTRAINT IF EXISTS orgs_max_apikey_expiration_days_check;

ALTER TABLE public.orgs
ADD CONSTRAINT orgs_max_apikey_expiration_days_check
CHECK (
max_apikey_expiration_days IS NULL
OR max_apikey_expiration_days BETWEEN 1 AND 365
);
44 changes: 43 additions & 1 deletion tests/apikeys-expiration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { BASE_URL, fetchWithRetry, getSupabaseClient, headers, ORG_ID, resetAndSeedAppData, resetAppData, TEST_EMAIL, USER_ID } from './test-utils.ts'
import { BASE_URL, fetchWithRetry, getAuthHeaders, getSupabaseClient, headers, ORG_ID, resetAndSeedAppData, resetAppData, TEST_EMAIL, USER_ID } from './test-utils.ts'

const id = randomUUID()
const APPNAME = `com.app.expiration.${id}`
Expand Down Expand Up @@ -426,6 +426,48 @@ describe('[PUT] /organization with API key policy', () => {
expect(response.status).toBe(400)
})

// Intentionally not `it.concurrent()`: this suite mutates shared org settings (`updateOrgId`)
// and order matters (see the last test toggling `require_apikey_expiration`).
it('rejects invalid max expiration days via direct org update RLS path', async () => {
const supabaseUrl = process.env.SUPABASE_URL?.replace(/\/$/, '')
if (!supabaseUrl)
throw new Error('SUPABASE_URL is missing for direct org update RLS test')

const authHeaders = await getAuthHeaders()
const restHeaders = {
...authHeaders,
apikey: process.env.SUPABASE_ANON_KEY ?? '',
Prefer: 'return=representation',
}

const { data: beforeUpdate, error: beforeUpdateError } = await getSupabaseClient()
.from('orgs')
.select('max_apikey_expiration_days')
.eq('id', updateOrgId)
.single()

expect(beforeUpdateError).toBeNull()

const response = await fetchWithRetry(`${supabaseUrl}/rest/v1/orgs?id=eq.${updateOrgId}`, {
method: 'PATCH',
headers: restHeaders,
body: JSON.stringify({
max_apikey_expiration_days: -1,
}),
})

expect(response.ok).toBe(false)

const { data: afterUpdate, error: afterUpdateError } = await getSupabaseClient()
.from('orgs')
.select('max_apikey_expiration_days')
.eq('id', updateOrgId)
.single()

expect(afterUpdateError).toBeNull()
expect(afterUpdate?.max_apikey_expiration_days).toBe(beforeUpdate?.max_apikey_expiration_days ?? null)
})

// This test must be last because it enables require_apikey_expiration,
// which would block subsequent tests using a non-expiring API key
it('update organization to require API key expiration', async () => {
Expand Down
79 changes: 64 additions & 15 deletions tests/organization-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,21 +171,21 @@ describe('read-mode API keys cannot access destructive organization routes', ()
expect(response.status).toBe(401)
})

it.concurrent('rejects POST /organization', async () => {
const response = await fetch(`${BASE_URL}/organization`, {
headers: { ...readOnlyHeaders, capgkey: readOnlyKey },
method: 'POST',
body: JSON.stringify({
orgId: readOnlyOrgId,
name: `Blocked create ${randomUUID()}`,
}),
})

expect(response.status).toBe(401)
// Ensure this is blocked by API-key auth (key mode allowlist), not by RLS deeper in the handler.
const payload = await response.json() as { error?: string }
expect(payload.error).toBe('invalid_apikey')
})
it.concurrent('rejects POST /organization', async () => {
const response = await fetch(`${BASE_URL}/organization`, {
headers: { ...readOnlyHeaders, capgkey: readOnlyKey },
method: 'POST',
body: JSON.stringify({
orgId: readOnlyOrgId,
name: `Blocked create ${randomUUID()}`,
}),
})

expect(response.status).toBe(401)
// Ensure this is blocked by API-key auth (key mode allowlist), not by RLS deeper in the handler.
const payload = await response.json() as { error?: string }
expect(payload.error).toBe('invalid_apikey')
})

it.concurrent('rejects DELETE /organization', async () => {
const response = await fetch(`${BASE_URL}/organization?orgId=${readOnlyOrgId}`, {
Expand Down Expand Up @@ -950,6 +950,55 @@ describe('[DELETE] /organization', () => {
})
})

describe('[PUT] /organization - encrypted bundles settings', () => {
afterAll(async () => {
await getSupabaseClient().from('orgs').update({
enforce_encrypted_bundles: false,
required_encryption_key: null,
}).eq('id', ORG_ID)
})

it('updates encrypted bundle enforcement and required key', async () => {
const requiredEncryptionKey = 'ABCDEFGHIJKLMNOPQRSTU'

const response = await fetch(`${BASE_URL}/organization`, {
headers,
method: 'PUT',
body: JSON.stringify({
orgId: ORG_ID,
enforce_encrypted_bundles: true,
required_encryption_key: requiredEncryptionKey,
}),
})
expect(response.status).toBe(200)

const { data, error } = await getSupabaseClient()
.from('orgs')
.select('enforce_encrypted_bundles, required_encryption_key')
.eq('id', ORG_ID)
.single()

expect(error).toBeNull()
expect(data?.enforce_encrypted_bundles).toBe(true)
expect(data?.required_encryption_key).toBe(requiredEncryptionKey)
})

it('rejects invalid required encryption key length', async () => {
const response = await fetch(`${BASE_URL}/organization`, {
headers,
method: 'PUT',
body: JSON.stringify({
orgId: ORG_ID,
required_encryption_key: 'too-short',
}),
})

expect(response.status).toBe(400)
const responseData = await response.json() as { error: string }
expect(responseData.error).toBe('invalid_required_encryption_key')
})
})

describe('[PUT] /organization - enforce_hashed_api_keys setting', () => {
it('update organization enforce_hashed_api_keys to true', async () => {
// First, ensure it's false
Expand Down
Loading