Skip to content
Merged
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
21 changes: 15 additions & 6 deletions internal/store/postgres/project_users_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down
159 changes: 159 additions & 0 deletions internal/store/postgres/project_users_repository_pg_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
4 changes: 2 additions & 2 deletions internal/store/postgres/project_users_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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,
},
Expand Down
Loading