Skip to content

feat(store): policy create checks that the role is live inside the insert transaction - #1966

Open
AmanGIT07 wants to merge 2 commits into
mainfrom
policy-create-live-role
Open

AmanGIT07 wants to merge 2 commits into
mainfrom
policy-create-live-role

Conversation

@AmanGIT07

Copy link
Copy Markdown
Contributor

What

  • PolicyRepository.Upsert reads the role from the live rows with FOR KEY SHARE inside the insert transaction, before the insert. No row returns role.ErrNotExist, which the create handler already maps to not found.

Why

A role delete now sets deleted_at and keeps the row (#1964), so the foreign key on policies.role_id no longer proves the role is live. The service reads the live role before calling the repository, but that read runs outside the transaction. A policy insert that starts while a role delete is running waits for the delete and then succeeds, because the row still exists. The locked read closes that order: it waits for the delete and returns no row once the delete commits. FOR KEY SHARE is the lock the foreign key check itself takes, so policy creates for the same role do not block each other.

Behaviour change

Creating a policy for a soft-deleted role returns not found instead of succeeding. Creates for live roles are unchanged.

Rollout

No migration, no config.

Tested

  • Repository suite against Postgres 13 in Docker, race detector on, two runs: a soft-deleted role returns not found and writes no row; a create that starts during an uncommitted role delete waits on the lock and then returns not found; a second create for the same role, while another create's insert is still open, does not wait.
  • With the live filter removed from the read, the first two cases fail: the create writes a policy for the deleted role, and the waiting create succeeds after the delete commits.
  • golangci-lint reports no issues on the package.

SQL Safety

  • The role id is bound as $1 through goqu.Ex{}.
  • ToSQL() params are forwarded unchanged.
  • No ? inside quoted SQL literals.
  • One // nolint on a deferred rollback in a test, the same as the role suite. None in source.

@vercel

vercel Bot commented Oct 5, 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 Oct 6, 2026 7:25am UTC

@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

📝 Summary

Summary by CodeRabbit

  • Bug Fixes
    • Policy creation now rejects roles that are missing or have been deleted, including when deletion happens concurrently, rather than creating a policy for an unavailable role.
    • Creating a policy no longer waits for a concurrent policy creation for the same role, helping requests complete without unnecessary delays. Attempts involving soft-deleted roles are rejected without creating a policy.

Walkthrough

PolicyRepository.Upsert now locks the requested live role before writing a policy. It returns role.ErrNotExist when no live role exists. Tests cover soft deletion and concurrent role and policy transactions.

Changes

Policy role locking

Layer / File(s) Summary
Lock role during policy upsert
internal/store/postgres/policy_repository.go
Upsert selects the live role with a FOR KEY SHARE lock before inserting or updating a policy. It returns role.ErrNotExist when the role is missing, without the transaction rollback wrapper.
Validate deleted-role and concurrent upserts
internal/store/postgres/policy_repository_test.go
Tests cover missing-role behavior after soft deletion, waiting for an uncommitted role deletion, and proceeding without waiting on an uncommitted policy insert. They also update the missing-role and missing-namespace cases and use ErrorIs for error assertions.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: whoabhisheksah

Merge Risk: 🔵 Low · up to c006a

The role-locking change itself looks sound. One concurrency test could be flaky because it counts every lock waiter in the database. Fixing that is a small, test-only follow-up.

Security Architecture Review

Security architecture risk: 🔵 Low · up to aa8b8

The change strengthens role-lifecycle consistency without adding an endpoint or granting additional authority. Conflicting locks protect the policy write, and errors trigger rollback. Remaining uncertainty concerns production-path concurrency coverage, not an observed security defect.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The coordination unit is the requested role row. Contention can affect policy writes and deletion involving that role across different principals and resources, but the change introduces no broader credential, network, or deployment authority.

Security Findings and Attack Paths

  • inferred — The inspected delete/create race is constrained in both directions: a deletion holding FOR UPDATE prevents a policy writer from passing the live-role check, while an earlier policy transaction holds its conflicting lock through commit so deletion subsequently checks the committed policy reference. This supports a strengthened lifecycle control, not an introduced attack path.

Trust Boundaries and Controls

  • observed — The service's earlier role lookup remains outside the transaction, but the repository now independently rechecks liveness under a lock before persistence. Repository errors stop the service before it writes authorization relations.

Resilience and Maintainability Implications

  • observed — Authorization relation creation remains a separate, sequential operation after the policy transaction commits and can return an error after partial progress. This behavior predates the PR; the new failure path exits before reaching it and does not weaken that existing boundary.
🚥 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.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Correct the missing-role error assertion. · policy_repository_test.go:197-199

internal/store/postgres/policy_repository_test.go:197-199
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the missing-role error assertion.

Upsert now returns role.ErrNotExist for the nonexistent role in the case at lines 175-182. That case still expects policy.ErrInvalidDetail. The condition errors.Is(tc.Err, err) also fails on a match and accepts a mismatch, so this test passes without checking the new contract. Set the expected error to role.ErrNotExist and assert s.Require().ErrorIs(err, tc.Err).


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: raystack/frontier/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 78913928-6bc8-4944-8b5a-a0eb9927133b
📥 Commits

Reviewing files that changed from the base of the PR and between d812a0a and decf6f2.

📒 Files selected for processing (2)
  • internal/store/postgres/policy_repository.go
  • internal/store/postgres/policy_repository_test.go

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

@coveralls

coveralls commented Oct 5, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37429504705

Coverage increased (+0.02%) to 54.369%

Details

  • Coverage increased (+0.02%) from the base build.
  • Patch coverage: 3 uncovered changes across 1 file (18 of 21 lines covered, 85.71%).
  • 2 coverage regressions across 1 file.

Uncovered Changes

File Changed Covered %
internal/store/postgres/policy_repository.go 21 18 85.71%

Coverage Regressions

2 previously-covered lines in 1 file lost coverage.

File Lines Losing Coverage Coverage
internal/store/postgres/policy_repository.go 2 74.82%

Coverage Stats

Coverage Status
Relevant Lines: 41408
Covered Lines: 22513
Line Coverage: 54.37%
Coverage Strength: 18.08 hits per line

💛 - Coveralls

@AmanGIT07

Copy link
Copy Markdown
Contributor Author

Fixed in aa8b809: the missing-role case now uses a random uuid and expects role.ErrNotExist, the namespace case carries valid ids so it reaches the foreign key, and the table asserts with ErrorIs instead of a check that passed on any error.

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Give the lock-wait check more than one second. · policy_repository_test.go:265-269

internal/store/postgres/policy_repository_test.go:265-269
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Give the lock-wait check more than one second.

If a loaded CI worker delays the goroutine before its FOR KEY SHARE query reaches PostgreSQL, the one-second Eventually deadline can fail before the test observes the wait. The client also gives that query a one-second timeout, so extending only the polling window is not enough.

Suggested timeout changes
+# In newTestClient's pgConfig:
-		MaxQueryTimeout: time.Millisecond * 1000,
+		MaxQueryTimeout: 10 * time.Second,
-		}, time.Second, 10*time.Millisecond, "the create did not wait for the role delete")
+		}, 5*time.Second, 10*time.Millisecond, "the create did not wait for the role delete")

ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: raystack/frontier/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 68d2a8a1-db65-4aed-931d-83934d94529d
📥 Commits

Reviewing files that changed from the base of the PR and between decf6f2 and aa8b809.

📒 Files selected for processing (1)
  • internal/store/postgres/policy_repository_test.go

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

…sert transaction

A soft-deleted role keeps its row, so the foreign key on policies.role_id no longer proves the role is live. The insert transaction now reads the role from the live rows with FOR KEY SHARE before the insert. That read waits for a role delete that is still running and returns not found once it commits, and it does not block other policy creates for the same role.
The missing-role case now uses a random uuid and expects the not-found error, the namespace case carries valid ids so it reaches the foreign key, and the table asserts with ErrorIs instead of a check that passed on any error.

@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: Repository: raystack/frontier/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 501bf322-e01f-4e24-a2e4-f163c82d09ec
📥 Commits

Reviewing files that changed from the base of the PR and between aa8b809 and c006ab7.

📒 Files selected for processing (1)
  • internal/store/postgres/policy_repository_test.go

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

Comment on lines +314 to +315
err := s.client.QueryRowxContext(s.ctx, "SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock'").Scan(&waiting)
return err == nil && waiting == 1

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Count waits on this transaction, not all database waits.

If another session waits for a lock in the same database, waiting == 1 can pass before this policy upsert blocks or fail when both sessions block. Record the deletion transaction’s backend PID. Then check whether the policy upsert is blocked by that PID, for example with pg_blocking_pids. (postgresql.org)

This branch was successfully deployed

1 active deployment
Preview — c006ab77 Deployed Oct 6, 2026 by vercel[bot]
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.

2 participants