From 156edbde0d580755916d2cf90aaedfbf1af43b82 Mon Sep 17 00:00:00 2001 From: aman Date: Thu, 24 Sep 2026 16:43:17 +0530 Subject: [PATCH 1/2] feat(store): project users search skips soft-deleted rows The search reads policies, users, and roles through the live-row helpers, and joins projects so a soft-deleted project reports no members, the same as a project id nobody owns. --- .../postgres/project_users_repository.go | 21 +++++++++++++------ .../postgres/project_users_repository_test.go | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/internal/store/postgres/project_users_repository.go b/internal/store/postgres/project_users_repository.go index a10090ca77..59f125353a 100644 --- a/internal/store/postgres/project_users_repository.go +++ b/internal/store/postgres/project_users_repository.go @@ -108,7 +108,7 @@ func (r ProjectUsersRepository) prepareDataQuery(projectID string, rql *rql.Quer } func (r ProjectUsersRepository) buildBaseQuery(projectID string) *goqu.SelectDataset { - return dialect.From(TABLE_POLICIES).Prepared(true). + return fromLive(TABLE_POLICIES).Prepared(true). Select( goqu.I(TABLE_USERS+"."+COLUMN_ID), goqu.I(TABLE_USERS+"."+COLUMN_NAME), @@ -130,11 +130,20 @@ func (r ProjectUsersRepository) buildBaseQuery(projectID string) *goqu.SelectDat goqu.T(TABLE_ROLES), goqu.On(goqu.I(TABLE_POLICIES+"."+COLUMN_ROLE_ID).Eq(goqu.I(TABLE_ROLES+"."+COLUMN_ID))), ). - Where(goqu.Ex{ - TABLE_POLICIES + "." + COLUMN_RESOURCE_ID: projectID, - TABLE_POLICIES + "." + COLUMN_RESOURCE_TYPE: RESOURCE_TYPE_PROJECT, - TABLE_POLICIES + "." + COLUMN_PRINCIPAL_TYPE: PRINCIPAL_TYPE_USER, - }). + InnerJoin( + goqu.T(TABLE_PROJECTS), + goqu.On(goqu.I(TABLE_POLICIES+"."+COLUMN_RESOURCE_ID).Eq(goqu.I(TABLE_PROJECTS+"."+COLUMN_ID))), + ). + Where( + goqu.Ex{ + TABLE_POLICIES + "." + COLUMN_RESOURCE_ID: projectID, + TABLE_POLICIES + "." + COLUMN_RESOURCE_TYPE: RESOURCE_TYPE_PROJECT, + TABLE_POLICIES + "." + COLUMN_PRINCIPAL_TYPE: PRINCIPAL_TYPE_USER, + }, + live(TABLE_USERS), + live(TABLE_ROLES), + live(TABLE_PROJECTS), + ). GroupBy( TABLE_USERS+"."+COLUMN_ID, TABLE_USERS+"."+COLUMN_NAME, diff --git a/internal/store/postgres/project_users_repository_test.go b/internal/store/postgres/project_users_repository_test.go index b1f2466ccc..cae6ff72e3 100644 --- a/internal/store/postgres/project_users_repository_test.go +++ b/internal/store/postgres/project_users_repository_test.go @@ -24,7 +24,7 @@ func TestProjectUsersRepository_PrepareDataQuery(t *testing.T) { Limit: 10, Offset: 0, }, - wantSQL: `SELECT "users"."id", "users"."name", "users"."email", "users"."title", "users"."avatar", "users"."state", "policies"."resource_id" AS "project_id", MIN("policies"."created_at") AS "project_joined_at", string_agg(DISTINCT roles.name, ',') AS "role_names", string_agg(DISTINCT roles.title, ',') AS "role_titles", string_agg(DISTINCT roles.id::text, ',') AS "role_ids" FROM "policies" INNER JOIN "users" ON ("policies"."principal_id" = "users"."id") INNER JOIN "roles" ON ("policies"."role_id" = "roles"."id") WHERE (("policies"."principal_type" = $1) AND ("policies"."resource_id" = $2) AND ("policies"."resource_type" = $3)) GROUP BY "users"."id", "users"."name", "users"."email", "users"."title", "users"."state", "policies"."resource_id" LIMIT $4`, + wantSQL: `SELECT "users"."id", "users"."name", "users"."email", "users"."title", "users"."avatar", "users"."state", "policies"."resource_id" AS "project_id", MIN("policies"."created_at") AS "project_joined_at", string_agg(DISTINCT roles.name, ',') AS "role_names", string_agg(DISTINCT roles.title, ',') AS "role_titles", string_agg(DISTINCT roles.id::text, ',') AS "role_ids" FROM "policies" INNER JOIN "users" ON ("policies"."principal_id" = "users"."id") INNER JOIN "roles" ON ("policies"."role_id" = "roles"."id") INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("policies"."deleted_at" IS NULL) AND (("policies"."principal_type" = $1) AND ("policies"."resource_id" = $2) AND ("policies"."resource_type" = $3)) AND ("users"."deleted_at" IS NULL) AND ("roles"."deleted_at" IS NULL) AND ("projects"."deleted_at" IS NULL)) GROUP BY "users"."id", "users"."name", "users"."email", "users"."title", "users"."state", "policies"."resource_id" LIMIT $4`, wantArgs: []any{"app/user", "project-123", "app/project", int64(10)}, wantErr: false, }, @@ -36,7 +36,7 @@ func TestProjectUsersRepository_PrepareDataQuery(t *testing.T) { Limit: 10, Offset: 0, }, - wantSQL: `SELECT * FROM (SELECT "users"."id", "users"."name", "users"."email", "users"."title", "users"."avatar", "users"."state", "policies"."resource_id" AS "project_id", MIN("policies"."created_at") AS "project_joined_at", string_agg(DISTINCT roles.name, ',') AS "role_names", string_agg(DISTINCT roles.title, ',') AS "role_titles", string_agg(DISTINCT roles.id::text, ',') AS "role_ids" FROM "policies" INNER JOIN "users" ON ("policies"."principal_id" = "users"."id") INNER JOIN "roles" ON ("policies"."role_id" = "roles"."id") WHERE (("policies"."principal_type" = $1) AND ("policies"."resource_id" = $2) AND ("policies"."resource_type" = $3)) GROUP BY "users"."id", "users"."name", "users"."email", "users"."title", "users"."state", "policies"."resource_id") AS "base" WHERE (("base"."name" ILIKE $4) OR ("base"."email" ILIKE $5) OR ("base"."title" ILIKE $6) OR ("base"."state" ILIKE $7) OR ("base"."role_names" ILIKE $8) OR ("base"."role_titles" ILIKE $9) OR ("base"."role_ids" ILIKE $10)) LIMIT $11`, + wantSQL: `SELECT * FROM (SELECT "users"."id", "users"."name", "users"."email", "users"."title", "users"."avatar", "users"."state", "policies"."resource_id" AS "project_id", MIN("policies"."created_at") AS "project_joined_at", string_agg(DISTINCT roles.name, ',') AS "role_names", string_agg(DISTINCT roles.title, ',') AS "role_titles", string_agg(DISTINCT roles.id::text, ',') AS "role_ids" FROM "policies" INNER JOIN "users" ON ("policies"."principal_id" = "users"."id") INNER JOIN "roles" ON ("policies"."role_id" = "roles"."id") INNER JOIN "projects" ON ("policies"."resource_id" = "projects"."id") WHERE (("policies"."deleted_at" IS NULL) AND (("policies"."principal_type" = $1) AND ("policies"."resource_id" = $2) AND ("policies"."resource_type" = $3)) AND ("users"."deleted_at" IS NULL) AND ("roles"."deleted_at" IS NULL) AND ("projects"."deleted_at" IS NULL)) GROUP BY "users"."id", "users"."name", "users"."email", "users"."title", "users"."state", "policies"."resource_id") AS "base" WHERE (("base"."name" ILIKE $4) OR ("base"."email" ILIKE $5) OR ("base"."title" ILIKE $6) OR ("base"."state" ILIKE $7) OR ("base"."role_names" ILIKE $8) OR ("base"."role_titles" ILIKE $9) OR ("base"."role_ids" ILIKE $10)) LIMIT $11`, wantArgs: []any{"app/user", "project-123", "app/project", "%john%", "%john%", "%john%", "%john%", "%john%", "%john%", "%john%", int64(10)}, wantErr: false, }, From 7bcf2ac4c5cc4e0465f1bde6f69009da0fb967bd Mon Sep 17 00:00:00 2001 From: aman Date: Thu, 24 Sep 2026 16:43:17 +0530 Subject: [PATCH 2/2] test(store): check the project users search against postgres with soft-deleted rows Seeds a soft-deleted user, grant, role, and project, then checks the member list, the reported roles, and the text search. --- .../project_users_repository_pg_test.go | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 internal/store/postgres/project_users_repository_pg_test.go diff --git a/internal/store/postgres/project_users_repository_pg_test.go b/internal/store/postgres/project_users_repository_pg_test.go new file mode 100644 index 0000000000..857a0510bd --- /dev/null +++ b/internal/store/postgres/project_users_repository_pg_test.go @@ -0,0 +1,159 @@ +package postgres_test + +import ( + "context" + "fmt" + "io" + "log/slog" + "testing" + + "github.com/ory/dockertest" + "github.com/raystack/frontier/core/aggregates/projectusers" + "github.com/raystack/frontier/internal/store/postgres" + "github.com/raystack/frontier/pkg/db" + "github.com/raystack/salt/rql" + "github.com/stretchr/testify/suite" +) + +// Runs the search against a real postgres to check that soft-deleted users, +// policies, roles, and projects stay out of the member list. +type ProjectUsersRepositoryPGTestSuite struct { + suite.Suite + ctx context.Context + client *db.Client + pool *dockertest.Pool + resource *dockertest.Resource + repository *postgres.ProjectUsersRepository + orgID string +} + +func (s *ProjectUsersRepositoryPGTestSuite) SetupSuite() { + var err error + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + s.client, s.pool, s.resource, err = newTestClient(logger) + if err != nil { + s.T().Fatal(err) + } + s.ctx = context.TODO() + s.repository = postgres.NewProjectUsersRepository(s.client) +} + +func (s *ProjectUsersRepositoryPGTestSuite) TearDownSuite() { + if err := purgeDocker(s.pool, s.resource); err != nil { + s.T().Fatal(err) + } +} + +func (s *ProjectUsersRepositoryPGTestSuite) SetupTest() { + s.exec(`INSERT INTO namespaces (name) VALUES ('app/project'), ('app/user') ON CONFLICT (name) DO NOTHING`) + + s.exec(`INSERT INTO organizations (name, title) VALUES ('pu-org', 'Project Users Org')`) + s.orgID = s.scalar(`SELECT id FROM organizations WHERE name = 'pu-org'`) + + s.exec(`INSERT INTO projects (name, title, org_id) VALUES + ('pu-live', 'Live Project', $1), ('pu-gone', 'Gone Project', $1)`, s.orgID) + + s.exec(`INSERT INTO users (name, email, title) VALUES + ('pu-alice', 'pu-alice@example.com', 'Alice'), + ('pu-bob', 'pu-bob@example.com', 'Bob'), + ('pu-cara', 'pu-cara@example.com', 'Cara'), + ('pu-dave', 'pu-dave@example.com', 'Dave')`) + + s.exec(`INSERT INTO roles (name, org_id) VALUES ('pu-role', $1), ('pu-role-gone', $1)`, s.orgID) + + s.policy("pu-live", "pu-alice", "pu-role") + s.policy("pu-live", "pu-bob", "pu-role") + s.policy("pu-live", "pu-cara", "pu-role") + s.policy("pu-live", "pu-dave", "pu-role-gone") + s.policy("pu-gone", "pu-alice", "pu-role") + + s.exec(`UPDATE projects SET deleted_at = now() WHERE name = 'pu-gone'`) + s.exec(`UPDATE users SET deleted_at = now() WHERE name = 'pu-bob'`) + s.exec(`UPDATE roles SET deleted_at = now() WHERE name = 'pu-role-gone'`) + s.softDeletePolicy("pu-live", "pu-cara") +} + +func (s *ProjectUsersRepositoryPGTestSuite) TearDownTest() { + queries := []string{} + for _, table := range []string{postgres.TABLE_POLICIES, postgres.TABLE_ROLES, postgres.TABLE_USERS, + postgres.TABLE_PROJECTS, postgres.TABLE_ORGANIZATIONS, postgres.TABLE_NAMESPACES} { + queries = append(queries, fmt.Sprintf("TRUNCATE TABLE %s RESTART IDENTITY CASCADE", table)) + } + if err := execQueries(s.ctx, s.client, queries); err != nil { + s.T().Fatal(err) + } +} + +func (s *ProjectUsersRepositoryPGTestSuite) exec(query string, args ...any) { + s.T().Helper() + execSQL(s.T(), s.ctx, s.client, query, args...) +} + +func (s *ProjectUsersRepositoryPGTestSuite) scalar(query string, args ...any) string { + s.T().Helper() + return scalarSQL(s.T(), s.ctx, s.client, query, args...) +} + +func (s *ProjectUsersRepositoryPGTestSuite) projectID(name string) string { + s.T().Helper() + return s.scalar(`SELECT id FROM projects WHERE name = $1`, name) +} + +func (s *ProjectUsersRepositoryPGTestSuite) userID(name string) string { + s.T().Helper() + return s.scalar(`SELECT id FROM users WHERE name = $1`, name) +} + +func (s *ProjectUsersRepositoryPGTestSuite) policy(projectName, userName, roleName string) { + s.T().Helper() + s.exec(`INSERT INTO policies (role_id, resource_id, resource_type, principal_id, principal_type) + VALUES ((SELECT id FROM roles WHERE name = $1), $2, 'app/project', $3, 'app/user')`, + roleName, s.projectID(projectName), s.userID(userName)) +} + +func (s *ProjectUsersRepositoryPGTestSuite) softDeletePolicy(projectName, userName string) { + s.T().Helper() + res := execSQL(s.T(), s.ctx, s.client, `UPDATE policies SET deleted_at = now() + WHERE resource_id = $1 AND principal_id = $2`, s.projectID(projectName), s.userID(userName)) + n, err := res.RowsAffected() + s.Require().NoError(err) + s.Require().EqualValues(1, n, "soft delete of %s for %s", projectName, userName) +} + +func (s *ProjectUsersRepositoryPGTestSuite) search(projectName, text string) []projectusers.AggregatedUser { + s.T().Helper() + res, err := s.repository.Search(s.ctx, s.projectID(projectName), &rql.Query{Limit: 50, Search: text}) + s.Require().NoError(err) + return res.Users +} + +func (s *ProjectUsersRepositoryPGTestSuite) names(users []projectusers.AggregatedUser) []string { + s.T().Helper() + out := make([]string, 0, len(users)) + for _, u := range users { + out = append(out, u.Name) + } + return out +} + +func (s *ProjectUsersRepositoryPGTestSuite) TestSkipsSoftDeletedMembersGrantsAndRoles() { + users := s.search("pu-live", "") + s.Require().Len(users, 1) + s.Equal("pu-alice", users[0].Name) + s.Equal([]string{"pu-role"}, users[0].RoleNames) +} + +func (s *ProjectUsersRepositoryPGTestSuite) TestSoftDeletedProjectHasNoMembers() { + s.Empty(s.names(s.search("pu-gone", ""))) +} + +func (s *ProjectUsersRepositoryPGTestSuite) TestSearchMatchesLiveRowsOnly() { + s.Equal([]string{"pu-alice"}, s.names(s.search("pu-live", "alice"))) + s.Empty(s.names(s.search("pu-live", "bob")), "the user is soft-deleted") + s.Empty(s.names(s.search("pu-live", "cara")), "the grant is soft-deleted") + s.Empty(s.names(s.search("pu-live", "dave")), "the role is soft-deleted") +} + +func TestProjectUsersRepositoryPG(t *testing.T) { + suite.Run(t, new(ProjectUsersRepositoryPGTestSuite)) +}