Files
paliad/internal/services/visibility.go
m abd99980fc fix(t-paliad-058): honor global_admin in visibilityPredicate
Mirror paliad.can_see_project's global-admin shortcut at the application
layer. The in-Go predicate previously relied on callers passing
user.GlobalRole as a separate :role / $roleArg parameter — the positional
variant compared against the literal 'admin' instead of 'global_admin',
so any global_admin without team membership got 404 from
/api/projects/{id} (and the other positional callsites: ListAncestors,
BuildTree, GetTree, deadline counts).

Fold the gate into a Go helper that resolves global_admin via EXISTS on
paliad.users, keyed only by userID. Callers no longer pass role, which
removes the foot-gun entirely. Drops the unused
visibilityPredicatePlaceholder dead helper.

Adds a regression test (visibility_test.go) covering global_admin +
standard user against GetByID and BuildTree without project_teams rows.
2026-04-27 16:35:55 +02:00

56 lines
2.4 KiB
Go

package services
// Application-layer mirror of paliad.can_see_project (defined in migration
// 023). The SQL function is enforced by RLS, but services run with a
// service-role connection that has no auth.uid() — so each query that
// returns project-scoped rows re-applies the same predicate inline.
//
// Predicate shape (matches can_see_project body):
//
// global_admin in paliad.users OR any (direct or ancestor) team membership
//
// We resolve the global-admin shortcut by EXISTS-querying paliad.users on
// the supplied userID rather than asking callers to pass user.GlobalRole as
// a separate parameter. That eliminates an entire bug class (mismatched
// role string, e.g. checking 'admin' when the column stores 'global_admin')
// and means the only argument the predicate needs is the user UUID.
//
// Callers must JOIN paliad.projects under the given alias so the path-walk
// half of the predicate can read alias.path.
import "fmt"
// visibilityPredicate returns the predicate using sqlx named bind variables.
// Caller binds :user_id to the requesting user's UUID.
//
// Uses CAST(... AS uuid[]) — not the `::uuid[]` shorthand — because sqlx
// v1.4.0's named-parameter parser strips one colon from `::uuid[]` while
// compiling `:user_id` placeholders, producing invalid SQL `:uuid[]` that
// Postgres rejects with `syntax error at or near ":"`.
func visibilityPredicate(alias string) string {
return `(EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = :user_id AND u.global_role = 'global_admin'
) OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = :user_id
AND pt.project_id = ANY(CAST(string_to_array(` + alias + `.path, '.') AS uuid[]))
))`
}
// visibilityPredicatePositional returns the predicate with one positional
// placeholder ($userArg) for the user UUID. Use when the surrounding query
// can't take named parameters (mixed positional args, lib/pq array binding,
// etc.). The same $userArg is referenced twice — that is a feature of
// Postgres positional binding, not a bug.
func visibilityPredicatePositional(alias string, userArg int) string {
return fmt.Sprintf(`(EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = $%d AND u.global_role = 'global_admin'
) OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $%d
AND pt.project_id = ANY(string_to_array(%s.path, '.')::uuid[])
))`, userArg, userArg, alias)
}