Skip to content

feat(store): policy, relation, and resource reads skip soft-deleted rows - #1936

Open
whoAbhishekSah wants to merge 2 commits into
mainfrom
soft-delete-live-reads-authz
Open

whoAbhishekSah wants to merge 2 commits into
mainfrom
soft-delete-live-reads-authz

Conversation

@whoAbhishekSah

Copy link
Copy Markdown
Member

Second of three PRs that make the identity repositories skip soft-deleted rows. Follows #1931.

What

  • Every read in the policy, relation, and resource repositories goes through fromLive, or adds live("p") directly where the query aliases the table.
  • The policy repository's small lookups of organization, project, and group titles for audit records use fromLive too.
  • The two updates keyed by id add the live filter.
  • The ON CONFLICT upserts are unchanged. They move together with the unique constraints in a later PR.

Behaviour change

None today. No row has deleted_at set yet.

Tested

One new test per repository: soft-delete a seeded row, then check every get, list, count, and update skips it. Store package passes with -race.

Every read in the three repositories goes through fromLive, or adds the live
filter directly where the query aliases the table. The policy repository's
small lookups of organization, project, and group titles for audit records
use fromLive too. The two updates keyed by id add the filter as well.

The ON CONFLICT upserts are unchanged. They move with the unique constraints
in a later change.
@vercel

vercel Bot commented Sep 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
frontier Ready Ready Preview Sep 21, 2026 4:18am UTC

@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Understand this PR’s impact

Explore downstream dependencies and potential security impact with Blast Radius.

View blast radius →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: raystack/frontier/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: e8a3f565-49af-445c-a9fa-93e7b2a62bf7

📥 Commits

Reviewing files that changed from the base of the PR and between 141a5ae and 6d0ae34.

📒 Files selected for processing (2)
  • internal/store/postgres/resource_repository.go
  • internal/store/postgres/resource_repository_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/store/postgres/resource_repository.go
  • internal/store/postgres/resource_repository_test.go

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.


📝 Summary

Summary by CodeRabbit

  • Bug Fixes

    • Soft-deleted policies, relations, and resources are now excluded from retrieval and listing results.
    • Attempts to retrieve or update soft-deleted records now correctly report that they do not exist.
    • Counts and membership-related results no longer include soft-deleted policies.
    • Reusing an identifier from a soft-deleted resource now correctly reports a conflict.
  • Tests

    • Added coverage verifying consistent handling of soft-deleted records across repository operations.

Walkthrough

PostgreSQL policy, relation, and resource repositories now use live-row filters for reads and applicable updates. Tests verify that soft-deleted records are excluded and return not-exist or conflict errors where applicable.

Changes

Repository live-row filtering

Layer / File(s) Summary
Policy live-row filtering
internal/store/postgres/policy_repository.go, internal/store/postgres/policy_repository_test.go
Policy lists, counts, lookups, member counts, resource metadata queries, and updates now filter for live rows. Tests cover excluded soft-deleted policies.
Relation live-row filtering
internal/store/postgres/relation_repository.go, internal/store/postgres/relation_repository_test.go
Relation list and lookup queries now use fromLive. Tests cover excluded soft-deleted relations.
Resource live-row filtering and conflict mapping
internal/store/postgres/resource_repository.go, internal/store/postgres/resource_repository_test.go
Resource list, lookups, and updates now filter for live rows. Duplicate-key errors now map to resource.ErrConflict. Tests cover soft-deleted resource behavior.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Feature

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coveralls

coveralls commented Sep 18, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 35560452772

Coverage increased (+0.2%) to 50.472%

Details

  • Coverage increased (+0.2%) from the base build.
  • Patch coverage: 1 uncovered change across 1 file (19 of 20 lines covered, 95.0%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
internal/store/postgres/relation_repository.go 4 3 75.0%
Total (3 files) 20 19 95.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 41124
Covered Lines: 20756
Line Coverage: 50.47%
Coverage Strength: 16.23 hits per line

💛 - Coveralls

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: afc335bf-b2e4-4815-9d2b-9a3083fff1eb

📥 Commits

Reviewing files that changed from the base of the PR and between f840f22 and 141a5ae.

📒 Files selected for processing (6)
  • internal/store/postgres/policy_repository.go
  • internal/store/postgres/policy_repository_test.go
  • internal/store/postgres/relation_repository.go
  • internal/store/postgres/relation_repository_test.go
  • internal/store/postgres/resource_repository.go
  • internal/store/postgres/resource_repository_test.go

Included review availability: Your plan provides up to 2 included reviews per hour; 1 remains after this review.

Comment thread internal/store/postgres/resource_repository.go
@whoAbhishekSah

Copy link
Copy Markdown
Member Author

Tested live on a local server built from this branch (main with #1931 merged). Fixtures: a user with an org viewer policy, a project viewer policy, and a platform admin relation, plus two custom resources under a live project. Ran every affected RPC before and after setting deleted_at directly in the DB, then exercised the write paths.

Path RPCs Result
Policy reads GetPolicy, ListPolicies not found / empty
Role and count reads off the policies table ListProjectUsers and ListOrganizationUsers with roles, ListOrganizationProjects with member count, ListOrganizationsByCurrentUser, ListProjectsByCurrentUser hidden policy dropped, counts drop by one
Relation reads GetRelation, ListPlatformUsers, ListRelations not found / row dropped
Resource reads and id updates GetProjectResource, ListProjectResources, ListResources, UpdateProjectResource, DeleteProjectResource not found / row dropped
SpiceDB-backed checks CheckResourcePermission on org, project, hidden resource; superuser check for the hidden platform relation still true, tuples untouched

Write paths against a hidden row, worth noting for the phased plan:

  • CreatePolicy, AddPlatformUser, and CreateProjectResource hit ON CONFLICT on the hidden row. Each returns success with the old id, and the row keeps deleted_at, so a Get on that id says not found. The resource title was even updated in place. This moves with the unique constraints, as the description says, but until then a caller gets a success and a ghost.
  • RemovePlatformUser and DeleteRelation return success but do nothing. The relation service looks the row up through the live filter first, finds nothing, and returns nil without deleting the SpiceDB tuple. The user stayed a superuser. A not found would be safer than a silent success.
  • DeletePolicy returns not found and RemoveOrganizationMember reports not a member while SpiceDB still grants. Consistent with the reads. The cascade work owns the tuple cleanup.

No behaviour change today since no row has deleted_at set.

The create upsert targets urn only. An insert that reuses an existing id with
a different urn fails on the primary key and fell through to a raw duplicate
key error. It now maps to resource.ErrConflict, which the handler already
turns into a conflict response. With live-only reads the id of a soft-deleted
row is invisible to the pre-check, so this path is easier to reach.
Comment on lines 396 to 401
SELECT id FROM ` + TABLE_POLICIES + `
WHERE resource_id = $2
AND resource_type = $3
AND role_id = $4
ORDER BY id
FOR UPDATE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The locked CTE still counts soft-deleted rows, so a deleted owner policy keeps the guard from firing and the last live owner can be removed. I checked with a test: one live owner plus one soft-deleted owner, and the guard deletes the live one. Can we add AND deleted_at IS NULL to the CTE and cover it with a test that soft-deletes the second holder?

Comment on lines 626 to +640
case schema.OrganizationNamespace:
orgID = resourceID
orgQuery, orgParams, _ := dialect.From(TABLE_ORGANIZATIONS).
orgQuery, orgParams, _ := fromLive(TABLE_ORGANIZATIONS).
Select("title").
Where(goqu.Ex{"id": resourceID}).
ToSQL()
_ = tx.QueryRowContext(ctx, orgQuery, orgParams...).Scan(&resourceName)
case schema.ProjectNamespace:
projQuery, projParams, _ := dialect.From(TABLE_PROJECTS).
projQuery, projParams, _ := fromLive(TABLE_PROJECTS).
Select("org_id", "title").
Where(goqu.Ex{"id": resourceID}).
ToSQL()
_ = tx.QueryRowContext(ctx, projQuery, projParams...).Scan(&orgID, &resourceName)
case schema.GroupNamespace:
grpQuery, grpParams, _ := dialect.From(TABLE_GROUPS).
grpQuery, grpParams, _ := fromLive(TABLE_GROUPS).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These lookups only fill the org id and title on the audit record. Once projects and groups are soft-deleted, a policy removed under one of them gets no org id and drops out of the org's audit view. The org title lookup in the audit insert reads all rows today. Can we keep these three on the plain From so the trail stays attributed?

Comment on lines 131 to 144
if len(flt.RolePermissions) > 0 {
// Join the roles table to keep only policies whose role grants at
// least one of the listed permission names.
stmt = stmt.
Join(
goqu.T(TABLE_ROLES).As("r"),
goqu.On(goqu.I("r.id").Eq(goqu.I("p.role_id"))),
).
Where(goqu.Func(
"jsonb_exists_any",
goqu.I("r.permissions"),
pq.Array(flt.RolePermissions),
))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The role join doesn't have deleted_at filter, will this be part of role repository change? If not, we can add it here

_, err = s.repository.GetByURN(s.ctx, deleted.URN)
s.Assert().ErrorIs(err, resource.ErrNotExist)

got, err := s.repository.List(s.ctx, resource.Filter{ProjectID: deleted.ProjectID})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This project has only one resource, and we just soft-deleted it. So got is empty here and the loop below never runs. The test only proves that List did not fail. Can we check the count instead, like the policy test does? For example, call List with no filter and expect len(s.resources)-1 rows.

_, err = s.repository.Get(s.ctx, deleted.ID)
s.Assert().ErrorIs(err, relation.ErrNotExist)

got, err := s.repository.List(s.ctx, relation.Filter{Subject: deleted.Subject, Object: deleted.Object})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Only one relation matches this subject and object, and we just soft-deleted it. So got is empty and the loop never runs. The same happens for byFields below. Can we check the count instead? For example, call List with no filter and expect one row less than we started with.

@AmanGIT07

Copy link
Copy Markdown
Contributor

About RemovePlatformUser and DeleteRelation returning success but doing nothing on a hidden row, from your testing note: is that fix meant for this PR, or is it planned for later? If later, which change will pick it up?

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.

3 participants