Skip to content

ENG-1426 Bug in permission functions#778

Open
maparent wants to merge 1 commit intomainfrom
eng-1426-bug-in-permission-functions
Open

ENG-1426 Bug in permission functions#778
maparent wants to merge 1 commit intomainfrom
eng-1426-bug-in-permission-functions

Conversation

@maparent
Copy link
Collaborator

@maparent maparent commented Feb 13, 2026

https://linear.app/discourse-graphs/issue/ENG-1426/bug-in-permission-functions

Pointed out by Devin in the group-test branch: there was still an issue with using the obsolete editor field of the SpaceAccess structure in that code.
Surprisingly the bug did not make the function fail, probably because the value is rarely set.
Corrected, and generalized the use of permissions as an input parameter to create_account_in_space.
Also, make sure that one cannot give permissions higher than one's own this way.


Open with Devin

@linear
Copy link

linear bot commented Feb 13, 2026

@supabase
Copy link

supabase bot commented Feb 13, 2026

Updates to Preview Branch (eng-1426-bug-in-permission-functions) ↗︎

Deployments Status Updated
Database Sat, 14 Feb 2026 01:19:05 UTC
Services Sat, 14 Feb 2026 01:19:05 UTC
APIs Sat, 14 Feb 2026 01:19:05 UTC

Tasks are run on every commit but only new migration files are pushed.
Close and reopen this PR if you want to apply changes from existing seed or migration files.

Tasks Status Updated
Configurations Sat, 14 Feb 2026 01:19:05 UTC
Migrations Sat, 14 Feb 2026 01:19:05 UTC
Seeding Sat, 14 Feb 2026 01:19:05 UTC
Edge Functions Sat, 14 Feb 2026 01:19:08 UTC

View logs for this Workflow Run ↗︎.
Learn more about Supabase for Git ↗︎.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 3 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +219 to +222
permissions_ := max(
my_permissions_in_space(space_id_),
COALESCE(local_account.permissions,
CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 max() used as two-argument scalar function but it's an aggregate function in PostgreSQL

The code uses max(value1, value2) as if it were a two-argument scalar comparison function, but in PostgreSQL max() is an aggregate function that accepts only a single argument. This will cause a runtime error when upsert_account_in_space is called for a user with a non-null dg_account.

Root Cause and Impact

At packages/database/supabase/schemas/account.sql:219-222 and the corresponding migration file at line 35-38:

permissions_ := max(
    my_permissions_in_space(space_id_),
    COALESCE(local_account.permissions,
        CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));

PostgreSQL's max() is an aggregate function (like SELECT max(column) FROM table) and does not accept two scalar arguments. Calling max(a, b) will produce an error like: function max(spaceaccesspermissions, spaceaccesspermissions) does not exist. There is no custom two-argument max function defined anywhere in the codebase.

The correct function for comparing two scalar values is GREATEST(a, b).

Impact: Every call to upsert_account_in_space (and by extension create_account_in_space, upsert_accounts_in_space) for a user with a linked dg_account (i.e., user_uid IS NOT NULL) will fail with a SQL error, completely breaking account creation/upsert for registered users.

Suggested change
permissions_ := max(
my_permissions_in_space(space_id_),
COALESCE(local_account.permissions,
CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));
permissions_ := GREATEST(
my_permissions_in_space(space_id_),
COALESCE(local_account.permissions,
CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +35 to +38
permissions_ := max(
my_permissions_in_space(space_id_),
COALESCE(local_account.permissions,
CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 max() used as two-argument scalar function in migration file (same bug as BUG-0001)

The migration file has the same max() vs GREATEST() bug as the schema file.

Root Cause

At packages/database/supabase/migrations/20260213155959_upsert_user_permissions.sql:35-38:

permissions_ := max(
    my_permissions_in_space(space_id_),
    COALESCE(local_account.permissions,
        CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));

Same issue as BUG-0001 — max() is an aggregate function in PostgreSQL, not a two-argument scalar comparison. This migration will fail when applied.

Impact: The migration itself will succeed (it's just a CREATE OR REPLACE FUNCTION), but any subsequent call to upsert_account_in_space for a user with user_uid IS NOT NULL will fail at runtime.

Suggested change
permissions_ := max(
my_permissions_in_space(space_id_),
COALESCE(local_account.permissions,
CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));
permissions_ := GREATEST(
my_permissions_in_space(space_id_),
COALESCE(local_account.permissions,
CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +219 to +222
permissions_ := max(
my_permissions_in_space(space_id_),
COALESCE(local_account.permissions,
CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 my_permissions_in_space checks the calling user's permissions instead of the target user's existing permissions

The max/GREATEST logic compares the calling user's permissions with the requested permissions, but the intent (based on the old code) is to not downgrade the target user's existing permissions.

Detailed Explanation

The old code at packages/database/supabase/migrations/20260124215014_PartialSpaceAccess.sql:40-42 used sa.editor (the existing value in the conflicting row) to preserve the target user's current permissions:

DO UPDATE SET permissions = CASE
    WHEN COALESCE(local_account.space_editor, sa.editor, true) THEN 'editor'
    ELSE 'reader' END;

The new code at packages/database/supabase/schemas/account.sql:219-222 replaces this with:

permissions_ := max(
    my_permissions_in_space(space_id_),
    COALESCE(local_account.permissions, ...));

my_permissions_in_space(space_id_) uses auth.uid() which returns the calling user's UID, not the target user's (user_uid). This means the comparison is against the wrong user's permissions.

Scenario: If user A (a reader) calls upsert_account_in_space for user B (currently an editor) with permissions='reader', the result would be GREATEST('reader', 'reader') = 'reader', which downgrades user B from editor to reader. The old code would have preserved user B's editor status.

Additionally, upsert_account_in_space is SECURITY DEFINER, so it runs as the function owner (postgres). When it calls my_permissions_in_space, auth.uid() returns the JWT-based calling user's UID. But the target user (user_uid) may be a completely different user whose existing permissions should be preserved.

Impact: Users' permissions can be inadvertently downgraded when another user with lower permissions triggers an account upsert.

Prompt for agents
The `my_permissions_in_space(space_id_)` function returns the calling user's (auth.uid()) permissions, but the intent is to preserve the target user's existing permissions so they aren't downgraded. Instead of calling `my_permissions_in_space`, you should query the target user's existing permissions directly. For example, replace the GREATEST/max call with something like:

SELECT permissions INTO permissions_ FROM public."SpaceAccess" WHERE space_id = space_id_ AND account_uid = user_uid;
permissions_ := GREATEST(
    permissions_,
    COALESCE(local_account.permissions,
        CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));

This change needs to be made in both:
1. packages/database/supabase/schemas/account.sql lines 219-222
2. packages/database/supabase/migrations/20260213155959_upsert_user_permissions.sql lines 35-38
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@maparent maparent force-pushed the eng-1426-bug-in-permission-functions branch from 23b9348 to 10e2212 Compare February 14, 2026 01:18
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +219 to +222
permissions_ := max(
my_permissions_in_space(space_id_),
COALESCE(local_account.permissions,
CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 GREATEST() grants permissions higher than caller's own, violating stated security intent

Even after fixing max() to GREATEST(), the logic computes the higher of the caller's permissions and the requested permissions. The PR description states the intent is to "make sure that one cannot give permissions higher than one's own," which requires LEAST(), not GREATEST().

Detailed Explanation

The enum ordering is 'partial' < 'reader' < 'editor' (defined at packages/database/supabase/schemas/account.sql:99-103). Using GREATEST(caller_perms, requested_perms) means:

  • If caller has 'reader' and requests 'editor' for another user → GREATEST('reader', 'editor') = 'editor' → grants editor access despite caller only being a reader.

The correct logic to enforce "cannot give permissions higher than one's own" is:

permissions_ := LEAST(
    my_permissions_in_space(space_id_),
    COALESCE(local_account.permissions, ...));

This would cap the granted permissions at the caller's own level.

Impact: A user with reader permissions could escalate another user's permissions to editor, which is a privilege escalation vulnerability. This affects both the schema file (packages/database/supabase/schemas/account.sql:219-222) and the migration (packages/database/supabase/migrations/20260213155959_upsert_user_permissions.sql:35-38).

Prompt for agents
In both packages/database/supabase/schemas/account.sql (lines 219-222) and packages/database/supabase/migrations/20260213155959_upsert_user_permissions.sql (lines 35-38), change GREATEST (or the current broken max) to LEAST to enforce the security constraint that a caller cannot grant permissions higher than their own:

permissions_ := LEAST(
    my_permissions_in_space(space_id_),
    COALESCE(local_account.permissions,
        CASE WHEN local_account.space_editor THEN 'editor' ELSE 'reader' END));

This ensures the granted permissions are capped at the caller's own permission level in the space.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant