Skip to content
Open
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
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
5 changes: 3 additions & 2 deletions supabase/functions/_backend/private/set_org_email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { safeParseSchema } from '../utils/ark_validation.ts'
import { BRES, parseBody, quickError, simpleError, useCors } from '../utils/hono.ts'
import { middlewareV2 } from '../utils/hono_middleware.ts'
import { updateCustomerEmail } from '../utils/stripe.ts'
import { supabaseWithAuth } from '../utils/supabase.ts'
import { supabaseAdmin, supabaseWithAuth } from '../utils/supabase.ts'

const bodySchema = type({
email: 'string.email',
Expand Down Expand Up @@ -66,7 +66,8 @@ app.post('/', middlewareV2(['all', 'write']), async (c) => {
await updateCustomerEmail(c, organization.customer_id, safeBody.email)

// Update supabase
const { error: updateOrgErr } = await supabase.from('orgs')
const { error: updateOrgErr } = await supabaseAdmin(c)
.from('orgs')
.update({ management_email: safeBody.email })
.eq('id', safeBody.org_id)

Expand Down
78 changes: 72 additions & 6 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 })
}
}

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,30 @@
: 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!)
}

const writeSupabase = shouldSyncStripeEmail ? supabaseAdmin(c) : supabase
let dataOrg: Database['public']['Tables']['orgs']['Row']
try {
dataOrg = await updateOrg(writeSupabase, 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 @@ -311,10 +374,13 @@
const rollbackFields = buildRollbackFields(currentOrg, updateFields)

try {
await updateOrg(supabase, body.orgId, rollbackFields, {
await updateOrg(writeSupabase, body.orgId, rollbackFields, {
expectedCurrentName: dataOrg.name,
expectedCurrentFields: buildExpectedCurrentFields(dataOrg, updateFields),
})
if (shouldSyncStripeEmail && currentOrg) {

Check warning on line 381 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,36 @@
DROP TRIGGER IF EXISTS "prevent_org_management_email_non_super_admin_update" ON "public"."orgs";
DROP TRIGGER IF EXISTS "prevent_org_management_email_direct_update" ON "public"."orgs";
DROP FUNCTION IF EXISTS "public"."prevent_org_management_email_non_super_admin_update"();

CREATE OR REPLACE FUNCTION "public"."prevent_org_management_email_direct_update"()
RETURNS trigger
LANGUAGE "plpgsql"
SECURITY DEFINER
SET "search_path" TO ''
AS $$
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;

RAISE EXCEPTION 'Management email updates must use the organization email sync endpoint'
USING ERRCODE = '42501';

RETURN NEW;
END;
$$;

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

CREATE TRIGGER "prevent_org_management_email_direct_update"
BEFORE UPDATE OF "management_email" ON "public"."orgs"
FOR EACH ROW
EXECUTE FUNCTION "public"."prevent_org_management_email_direct_update"();
Loading
Loading