Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion cli/skills/organization-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ Use this skill for account and organization administration commands.
- `npx @capgo/cli@latest organization set ORG_ID --enforce-hashed-api-keys`
- Notes:
- Security settings require `super_admin` role.
- Management email updates require `super_admin` role and sync the billing customer email through the private organization email endpoint.
- Key options:
- `-n, --name <name>`
- `-e, --email <email>`
- `-e, --email <email>` (requires `super_admin`)
- `--enforce-2fa`, `--no-enforce-2fa`
- `--password-policy`, `--no-password-policy`
- `--min-length <minLength>`
Expand Down
6 changes: 3 additions & 3 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ organization
.alias('s')
.description(`⚙️ Update organization settings including name, email, security policies, and enforcement options.

Security settings require super_admin role.
Security settings and management email updates require super_admin role.

Example: npx @capgo/cli@latest organization set ORG_ID --name "New Name"
Example: npx @capgo/cli@latest organization set ORG_ID --enforce-2fa
Expand All @@ -616,7 +616,7 @@ Example: npx @capgo/cli@latest organization set ORG_ID --require-apikey-expirati
Example: npx @capgo/cli@latest organization set ORG_ID --enforce-hashed-api-keys`)
.action(setOrganization)
.option('-n, --name <name>', `Organization name`)
.option('-e, --email <email>', `Management email for the organization`)
.option('-e, --email <email>', `Management email for the organization (requires super_admin)`)
.option('--enforce-2fa', `Enable 2FA enforcement for all organization members`)
.option('--no-enforce-2fa', `Disable 2FA enforcement for organization`)
.option('--password-policy', `Enable password policy enforcement for organization`)
Expand Down Expand Up @@ -698,7 +698,7 @@ organisation
.description(`[DEPRECATED] Use "organization set" instead.`)
.action(setOrganization)
.option('-n, --name <name>', `Organization name`)
.option('-e, --email <email>', `Management email for the organization`)
.option('-e, --email <email>', `Management email for the organization (requires super_admin)`)
.option('--enforce-2fa', `Enable 2FA enforcement for all organization members`)
.option('--no-enforce-2fa', `Disable 2FA enforcement for organization`)
.option('--password-policy', `Enable password policy enforcement for organization`)
Expand Down
44 changes: 33 additions & 11 deletions cli/src/organization/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ import {
sendEvent,
} from '../utils'

type SupabaseClient = Awaited<ReturnType<typeof createSupabaseClient>>

async function updateOrganizationManagementEmail(
supabase: SupabaseClient,
orgId: string,
email: string,
silent: boolean,
) {
const { error } = await supabase.functions.invoke('private/set_org_email', {
body: JSON.stringify({ org_id: orgId, email }),
})

if (error) {
if (!silent)
log.error(`Could not update organization management email: ${formatError(error)}`)
throw new Error(`Could not update organization management email: ${formatError(error)}`)
}
}

export async function setOrganizationInternal(
orgId: string,
options: OrganizationSetOptions,
Expand Down Expand Up @@ -399,18 +418,21 @@ export async function setOrganizationInternal(
if (!silent)
log.info(`Updating organization "${orgId}"`)

const { error: dbError } = await supabase
.from('orgs')
.update({
name,
management_email: email,
})
.eq('id', orgId)
if (name !== orgData.name) {
const { error: dbError } = await supabase
.from('orgs')
.update({ name })
.eq('id', orgId)

if (dbError) {
if (!silent)
log.error(`Could not update organization ${formatError(dbError)}`)
throw new Error(`Could not update organization: ${formatError(dbError)}`)
if (dbError) {
if (!silent)
log.error(`Could not update organization ${formatError(dbError)}`)
throw new Error(`Could not update organization: ${formatError(dbError)}`)
}
}

if (email !== orgData.management_email) {
await updateOrganizationManagementEmail(supabase, orgId, email, silent)
}

await sendEvent(enrichedOptions.apikey, {
Expand Down
2 changes: 1 addition & 1 deletion cli/webdocs/organisation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ npx @capgo/cli@latest organisation set
| Param | Type | Description |
| -------------- | ------------- | -------------------- |
| **-n** | <code>string</code> | Organization name |
| **-e** | <code>string</code> | Management email for the organization |
| **-e** | <code>string</code> | Management email for the organization (requires super_admin) |
| **--enforce-2fa** | <code>boolean</code> | Enable 2FA enforcement for all organization members |
| **--no-enforce-2fa** | <code>boolean</code> | Disable 2FA enforcement for organization |
| **--password-policy** | <code>boolean</code> | Enable password policy enforcement for organization |
Expand Down
4 changes: 2 additions & 2 deletions cli/webdocs/organization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ npx @capgo/cli@latest organization set
```

⚙️ Update organization settings including name, email, security policies, and enforcement options.
Security settings require super_admin role.
Security settings and management email updates require super_admin role.

**Example:**

Expand All @@ -109,7 +109,7 @@ npx @capgo/cli@latest organization set ORG_ID --name "New Name"
| Param | Type | Description |
| -------------- | ------------- | -------------------- |
| **-n** | <code>string</code> | Organization name |
| **-e** | <code>string</code> | Management email for the organization |
| **-e** | <code>string</code> | Management email for the organization (requires super_admin) |
| **--enforce-2fa** | <code>boolean</code> | Enable 2FA enforcement for all organization members |
| **--no-enforce-2fa** | <code>boolean</code> | Disable 2FA enforcement for organization |
| **--password-policy** | <code>boolean</code> | Enable password policy enforcement for organization |
Expand Down
75 changes: 70 additions & 5 deletions supabase/functions/_backend/public/organization/put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { quickError, simpleError } from '../../utils/hono.ts'
import { checkPermission } from '../../utils/rbac.ts'
import { createSignedImageUrl, normalizeImagePath } from '../../utils/storage.ts'
import { getStripeCustomerName, isDeterministicStripeCustomerUpdateError, updateCustomerOrganizationName } from '../../utils/stripe.ts'
import { getStripeCustomerName, isDeterministicStripeCustomerUpdateError, updateCustomerEmail, updateCustomerOrganizationName } from '../../utils/stripe.ts'
import { apikeyHasOrgRightWithPolicy, supabaseAdmin, supabaseApikey, supabaseClient } from '../../utils/supabase.ts'
import { normalizeWebsiteUrl } from './website.ts'

Expand Down Expand Up @@ -74,6 +74,28 @@
throw simpleError('cannot_access_organization', 'You can\'t access this organization', { orgId })
}

async function ensureManagementEmailAccess(
supabase: ReturnType<typeof supabaseApikey>,
orgId: string,
authUserId: string,
) {
const userRight = await supabase.rpc('check_min_rights', {
min_right: 'super_admin',
org_id: orgId,
user_id: authUserId,
channel_id: null as any,
app_id: null as any,
})

if (userRight.error) {
throw simpleError('internal_auth_error', 'Internal auth error', { userRight })
}

if (!userRight.data) {
throw quickError(403, 'not_authorized', 'Only organization super admins can update the management email', { orgId })
Comment thread
slashdevcorpse marked this conversation as resolved.
}
}

function validateMaxExpirationDays(maxDays?: number | null) {
if (maxDays === undefined || maxDays === null) {
return
Expand Down Expand Up @@ -257,6 +279,26 @@
return error
}

async function rollbackStripeCustomerEmail(
c: Context<MiddlewareKeyVariables>,
currentOrg: OrgRow,
originalError: unknown,
) {
if (!currentOrg.customer_id) {
return
}

try {
await updateCustomerEmail(c, currentOrg.customer_id, currentOrg.management_email)
}
catch (rollbackError) {
throw simpleError('cannot_update_org', 'Cannot update org', {
error: getErrorDetail(originalError),
rollbackError: getErrorDetail(rollbackError),
})
}
}

export async function put(
c: Context<MiddlewareKeyVariables>,
bodyRaw: any,
Expand All @@ -275,6 +317,10 @@

// Auth context is already set by middlewareV2
await ensureOrgAccess(c, apikey, body.orgId, supabase)
const shouldSyncStripeEmail = body.management_email !== undefined
if (body.management_email !== undefined) {
await ensureManagementEmailAccess(supabase, body.orgId, authUserId)
}

if (body.enforcing_2fa) {
await enforceSelf2faRequirement(authUserId, c)
Expand All @@ -287,13 +333,29 @@
: undefined
const updateFields = buildUpdateFields(body, sanitizedOrgName)
const shouldSyncStripeName = body.name !== undefined
const currentOrg = shouldSyncStripeName
const currentOrg = shouldSyncStripeName || shouldSyncStripeEmail
? await getOrgForNameSync(supabase, body.orgId)
: null

const dataOrg: Database['public']['Tables']['orgs']['Row'] = await updateOrg(supabase, body.orgId, updateFields, {
expectedCurrentName: shouldSyncStripeName ? currentOrg?.name : undefined,
})
if (shouldSyncStripeEmail) {
if (!currentOrg?.customer_id) {
throw simpleError('org_does_not_have_customer', 'Organization does not have a customer id', { orgId: body.orgId })
}
await updateCustomerEmail(c, currentOrg.customer_id, body.management_email!)
}

let dataOrg: Database['public']['Tables']['orgs']['Row']
try {
dataOrg = await updateOrg(supabase, body.orgId, updateFields, {
expectedCurrentName: shouldSyncStripeName ? currentOrg?.name : undefined,
})
}
catch (updateError) {
if (shouldSyncStripeEmail && currentOrg) {
await rollbackStripeCustomerEmail(c, currentOrg, updateError)
}
throw updateError
}

const committedCustomerId = dataOrg.customer_id

Expand All @@ -315,6 +377,9 @@
expectedCurrentName: dataOrg.name,
expectedCurrentFields: buildExpectedCurrentFields(dataOrg, updateFields),
})
if (shouldSyncStripeEmail && currentOrg) {

Check warning on line 380 in supabase/functions/_backend/public/organization/put.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This always evaluates to truthy. Consider refactoring this code.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ4T_jR_STaYLGQ_BFnW&open=AZ4T_jR_STaYLGQ_BFnW&pullRequest=2104
await rollbackStripeCustomerEmail(c, currentOrg, stripeError)
}
}
catch (rollbackError) {
throw simpleError('cannot_update_org', 'Cannot update org', {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
CREATE OR REPLACE FUNCTION "public"."prevent_org_management_email_non_super_admin_update"()
RETURNS trigger
LANGUAGE "plpgsql"
SECURITY DEFINER
SET "search_path" TO ''
AS $$
DECLARE
v_request_user uuid;
BEGIN
IF NEW.management_email IS NOT DISTINCT FROM OLD.management_email THEN
RETURN NEW;
END IF;

IF (SELECT auth.role()) = 'service_role' THEN
RETURN NEW;
END IF;

v_request_user := public.get_identity_org_allowed('{all,write}'::public.key_mode[], OLD.id);

IF v_request_user IS NULL OR NOT public.check_min_rights(
'super_admin'::public.user_min_right,
v_request_user,
OLD.id,
NULL::character varying,
NULL::bigint
) THEN
RAISE EXCEPTION 'Only organization super admins can update the management email'
USING ERRCODE = '42501';
END IF;

RETURN NEW;
END;
$$;

ALTER FUNCTION "public"."prevent_org_management_email_non_super_admin_update"() OWNER TO "postgres";
REVOKE ALL ON FUNCTION "public"."prevent_org_management_email_non_super_admin_update"() FROM PUBLIC;
REVOKE ALL ON FUNCTION "public"."prevent_org_management_email_non_super_admin_update"() FROM "anon";
REVOKE ALL ON FUNCTION "public"."prevent_org_management_email_non_super_admin_update"() FROM "authenticated";
GRANT ALL ON FUNCTION "public"."prevent_org_management_email_non_super_admin_update"() TO "service_role";

DROP TRIGGER IF EXISTS "prevent_org_management_email_non_super_admin_update" ON "public"."orgs";
CREATE TRIGGER "prevent_org_management_email_non_super_admin_update"
BEFORE UPDATE OF "management_email" ON "public"."orgs"
FOR EACH ROW
EXECUTE FUNCTION "public"."prevent_org_management_email_non_super_admin_update"();
102 changes: 102 additions & 0 deletions tests/organization-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,108 @@ describe('[PUT] /organization', () => {
}
})

it.concurrent('rejects management email updates from org admins', async () => {
const orgId = randomUUID()
const orgName = `Admin Email Boundary Organization ${new Date().toISOString()}`
const attemptedEmail = `admin-bypass-${randomUUID()}@example.com`
const { error: createError } = await getSupabaseClient().from('orgs').insert({
id: orgId,
name: orgName,
management_email: TEST_EMAIL,
created_by: USER_ID_2,
use_new_rbac: false,
})
if (createError)
throw createError

const { error: orgUserError } = await getSupabaseClient().from('org_users').insert({
org_id: orgId,
user_id: USER_ID,
user_right: 'admin',
})
if (orgUserError)
throw orgUserError

try {
const adminHeaders = await getAuthHeadersForCredentials(USER_EMAIL, USER_PASSWORD)
const response = await fetch(`${BASE_URL}/organization`, {
headers: adminHeaders,
method: 'PUT',
body: JSON.stringify({ orgId, management_email: attemptedEmail }),
})
expect(response.status).toBe(403)
const responseData = await response.json() as { error: string }
expect(responseData.error).toBe('not_authorized')

const { data, error } = await getSupabaseClient()
.from('orgs')
.select('management_email')
.eq('id', orgId)
.single()
expect(error).toBeNull()
expect(data?.management_email).toBe(TEST_EMAIL)
}
finally {
await getSupabaseClient().from('org_users').delete().eq('org_id', orgId)
await getSupabaseClient().from('orgs').delete().eq('id', orgId)
}
})

it.concurrent('rejects direct management email updates from org admins', async () => {
const orgId = randomUUID()
const orgName = `Direct Admin Email Boundary Organization ${new Date().toISOString()}`
const attemptedEmail = `direct-admin-bypass-${randomUUID()}@example.com`
const { error: createError } = await getSupabaseClient().from('orgs').insert({
id: orgId,
name: orgName,
management_email: TEST_EMAIL,
created_by: USER_ID_2,
use_new_rbac: false,
})
if (createError)
throw createError

const { error: orgUserError } = await getSupabaseClient().from('org_users').insert({
org_id: orgId,
user_id: USER_ID,
user_right: 'admin',
})
if (orgUserError)
throw orgUserError

try {
const adminHeaders = await getAuthHeadersForCredentials(USER_EMAIL, USER_PASSWORD)
const adminClient = createClient(normalizedSupabaseBaseUrl, SUPABASE_ANON_KEY, {
auth: {
persistSession: false,
},
global: {
headers: adminHeaders,
},
})

const { error: updateError } = await adminClient
.from('orgs')
.update({ management_email: attemptedEmail })
.eq('id', orgId)

expect(updateError).toBeTruthy()
expect(updateError?.message).toContain('Only organization super admins can update the management email')

const { data, error } = await getSupabaseClient()
.from('orgs')
.select('management_email')
.eq('id', orgId)
.single()
expect(error).toBeNull()
expect(data?.management_email).toBe(TEST_EMAIL)
}
finally {
await getSupabaseClient().from('org_users').delete().eq('org_id', orgId)
await getSupabaseClient().from('orgs').delete().eq('id', orgId)
}
})

it('update organization with invalid body', async () => {
const response = await fetch(`${BASE_URL}/organization`, {
headers,
Expand Down
Loading