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.
56 lines
2.4 KiB
Go
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)
|
|
}
|