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
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
CREATE OR REPLACE FUNCTION public.get_organization_cli_warnings(
orgid uuid,
cli_version text
)
RETURNS jsonb []
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
messages jsonb[] := ARRAY[]::jsonb[];
has_read_access boolean;
api_key_text text;
api_key public.apikeys%ROWTYPE;
BEGIN
PERFORM cli_version;

SELECT public.get_apikey_header()
INTO api_key_text;

IF api_key_text IS NOT NULL THEN
SELECT *
INTO api_key
FROM public.find_apikey_by_value(api_key_text)
LIMIT 1;
END IF;

SELECT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_apikey_only('{write,all,upload,read}'::public.key_mode[]),
orgid,
NULL::varchar,
NULL::bigint
)
INTO has_read_access;

IF NOT COALESCE(has_read_access, false) THEN
IF api_key_text IS NULL OR api_key.id IS NULL OR public.is_apikey_expired(api_key.expires_at) THEN
messages := pg_catalog.array_append(messages, pg_catalog.jsonb_build_object(
'message',
'API key does not have read access to this organization',
'fatal',
true
));
END IF;

-- Upload performs app-scoped permission and plan checks after this RPC.
-- App-scoped API keys may legitimately upload without org-level read access,
-- so skip org warnings instead of blocking the upload here.
RETURN messages;
Comment thread
riderx marked this conversation as resolved.
Comment thread
riderx marked this conversation as resolved.
END IF;

IF (
public.is_paying_and_good_plan_org_action(orgid, ARRAY['mau']::public.action_type[]) = true
AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['bandwidth']::public.action_type[]) = true
AND public.is_paying_and_good_plan_org_action(orgid, ARRAY['storage']::public.action_type[]) = false
) THEN
messages := pg_catalog.array_append(messages, pg_catalog.jsonb_build_object(
'message',
'You have exceeded your storage limit.

Check failure on line 60 in supabase/migrations/20260507171200_skip_cli_warning_read_fatal_for_scoped_keys.sql

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

An illegal character with code point 10 was found in this literal.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ4EkYRBAQ_Df9u5SySe&open=AZ4EkYRBAQ_Df9u5SySe&pullRequest=2075
Upload will fail, but you can still download your data.
MAU and bandwidth limits are not exceeded.
In order to upload your plan, please upgrade your plan here: https://console.capgo.app/settings/plans.',
'fatal',
true
));
END IF;

RETURN messages;
END;
$$;

ALTER FUNCTION public.get_organization_cli_warnings(uuid, text)
OWNER TO postgres;
REVOKE ALL ON FUNCTION public.get_organization_cli_warnings(uuid, text)
FROM public;
GRANT ALL ON FUNCTION public.get_organization_cli_warnings(uuid, text)
TO anon;
GRANT ALL ON FUNCTION public.get_organization_cli_warnings(uuid, text)
TO authenticated;
GRANT ALL ON FUNCTION public.get_organization_cli_warnings(uuid, text)
TO service_role;
82 changes: 82 additions & 0 deletions tests/rbac-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,88 @@ describe('rbac permission system', () => {
expect(deniedResult.rows[0].allowed).toBe(false)
expect(allowedResult.rows[0].allowed).toBe(true)
})

it('returns no CLI warnings for an app-scoped API key without org read', async () => {
const id = randomUUID()
const orgId = randomUUID()
const appUuid = randomUUID()
const appId = `com.cli.warning.${id}`
const scopedKey = `rbac-cli-warning-${id}`

await query(`
INSERT INTO public.orgs (id, name, management_email, created_by, use_new_rbac)
VALUES ($1::uuid, $2, $3, $4::uuid, true)
`, [orgId, `CLI Warning Org ${id}`, `cli-warning-${id}@capgo.app`, USER_ID])

await query(`
INSERT INTO public.apps (id, app_id, icon_url, owner_org, name)
VALUES ($1::uuid, $2, $3, $4::uuid, $5)
`, [appUuid, appId, 'https://example.com/icon.png', orgId, `CLI Warning App ${id}`])

await query(`
INSERT INTO public.apikeys (user_id, key, key_hash, mode, name, limited_to_orgs, limited_to_apps)
VALUES ($1::uuid, $2, NULL, NULL, $3, ARRAY[$4::uuid], ARRAY[$5]::varchar[])
`, [USER_ID, scopedKey, `CLI warning scoped ${id}`, orgId, appId])

await query(`
INSERT INTO public.role_bindings (
principal_type,
principal_id,
role_id,
scope_type,
org_id,
app_id,
granted_by,
reason,
is_direct
)
SELECT
public.rbac_principal_apikey(),
ak.rbac_id,
r.id,
public.rbac_scope_app(),
$2::uuid,
$3::uuid,
$4::uuid,
'cli warning app-scoped key regression',
true
FROM public.apikeys ak
CROSS JOIN public.roles r
WHERE ak.key = $1
AND r.name = public.rbac_role_app_admin()
LIMIT 1
`, [scopedKey, orgId, appUuid, USER_ID])

await query(`SELECT set_config('request.headers', jsonb_build_object('capgkey', $1::text)::text, true)`, [scopedKey])

const orgReadResult = await query(`
SELECT public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_apikey_only('{write,all,upload,read}'::public.key_mode[]),
$1::uuid,
NULL::varchar,
NULL::bigint
) AS allowed
`, [orgId])

const uploadResult = await query(`
SELECT public.check_min_rights(
'upload'::public.user_min_right,
public.get_identity_apikey_only('{write,all,upload,read}'::public.key_mode[]),
$1::uuid,
$2,
NULL::bigint
) AS allowed
`, [orgId, appId])

const warningsResult = await query(`
SELECT cardinality(public.get_organization_cli_warnings($1::uuid, '7.95.12')) AS warning_count
`, [orgId])

expect(orgReadResult.rows[0].allowed).toBe(false)
expect(uploadResult.rows[0].allowed).toBe(true)
expect(warningsResult.rows[0].warning_count).toBe(0)
})
})

describe('feature flag routing', () => {
Expand Down
Loading