Skip to content

feat: add invoice access control for reviewers#100

Merged
emilwojtaszek merged 6 commits into
mainfrom
feat/invoice-access-control
Mar 24, 2026
Merged

feat: add invoice access control for reviewers#100
emilwojtaszek merged 6 commits into
mainfrom
feat/invoice-access-control

Conversation

@emilwojtaszek

@emilwojtaszek emilwojtaszek commented Mar 24, 2026

Copy link
Copy Markdown
Member

Allow admins/owners to restrict invoice visibility so only explicitly granted reviewers can see them. Adds access_restricted boolean flag on invoices and invoice_access_grants join table. Refactors role checks to use Authorization.can? instead of hardcoded pattern matching.

  • Migration: access_restricted column + invoice_access_grants table
  • Schema: InvoiceAccessGrant with belongs_to invoice/user/granted_by
  • Context: grant_access, revoke_access, set_access_restricted, list_access_grants
  • Query filtering: maybe_filter_by_access applied to all invoice queries
  • LiveView: Notion-style access control card on invoice show page
  • Threads user_id through all invoice query callers (LiveView + API)
  • 27 new tests (context CRUD, filtering by role, LiveView card)

Summary by CodeRabbit

  • New Features
    • Per-invoice access grants and an access_restricted flag; income invoices are auto-marked restricted and cannot be un-restricted.
  • UI & API
    • Access management UI card (for managers) and API endpoints to view/toggle restriction and grant/revoke access; invoices now include access_restricted.
  • Behavior Change
    • Reviewer visibility is governed by grants/user-scoped filtering instead of type-based scoping; owners/admins/accountants retain full visibility.
  • Tests
    • Expanded tests for grant lifecycle, visibility filtering, UI and API flows.
  • Chores
    • DB migrations, schema/docs, and test factory updates.

Allow admins/owners to restrict invoice visibility so only explicitly
granted reviewers can see them. Adds access_restricted boolean flag on
invoices and invoice_access_grants join table. Refactors role checks to
use Authorization.can? instead of hardcoded pattern matching.

- Migration: access_restricted column + invoice_access_grants table
- Schema: InvoiceAccessGrant with belongs_to invoice/user/granted_by
- Context: grant_access, revoke_access, set_access_restricted, list_access_grants
- Query filtering: maybe_filter_by_access applied to all invoice queries
- LiveView: Notion-style access control card on invoice show page
- Threads user_id through all invoice query callers (LiveView + API)
- 27 new tests (context CRUD, filtering by role, LiveView card)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Mar 24, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

Adds invoice access control: new InvoiceAccessGrant schema and migrations, invoice access_restricted field and association, access-grant CRUD and access-aware filtering in the Invoices context, controller/LiveView endpoints and UI changes, OpenAPI schema updates, factories and extensive tests.

Changes

Cohort / File(s) Summary
Access Control Schema & Migrations
lib/ksef_hub/invoices/invoice_access_grant.ex, priv/repo/migrations/20260324000000_add_invoice_access_control.exs, priv/repo/migrations/20260324064116_restrict_income_invoices.exs
Adds InvoiceAccessGrant Ecto schema and migrations: new invoice_access_grants table, access_restricted column on invoices, and a migration to mark existing income invoices restricted.
Invoice Schema
lib/ksef_hub/invoices/invoice.ex
Adds field :access_restricted, :boolean, default: false and has_many :access_grants association.
Invoices Context
lib/ksef_hub/invoices.ex
Introduces access APIs: list_access_grants/1, grant_access/3, revoke_access/2, set_access_restricted/2; threads opts (role, user_id) into list/count/paginated/get flows; replaces role/type scoping with maybe_filter_by_access/3; enforces income invoices as restricted on create/upsert.
API Controller & Routes
lib/ksef_hub_web/controllers/api/invoice_controller.ex, lib/ksef_hub_web/router.ex
Adds routes and controller actions for access management (get_access, set_access, grant_access, revoke_access), applies :manage_team plug to them, passes user_id from API token into invoice calls, and includes access_restricted in JSON responses.
LiveView Updates
lib/ksef_hub_web/live/.../show.ex, .../index.ex, .../classify.ex, lib/ksef_hub_web/live/payment_request_live/form.ex
LiveViews and payment-request form now pass user_id to invoice queries, remove prior client-side type sanitization, add access-management UI and LiveView events (toggle_access_restricted, grant_access, revoke_access) gated by manage_team.
PDF Controller
lib/ksef_hub_web/controllers/invoice_pdf_controller.ex
Threads user_id into get_invoice_with_details/3 for PDF/XML flows.
OpenAPI Schemas
lib/ksef_hub_web/schemas/access_grant.ex, lib/ksef_hub_web/schemas/access_grant_list_response.ex, lib/ksef_hub_web/schemas/invoice.ex
Adds AccessGrant and AccessGrantListResponse schemas and adds access_restricted boolean to the Invoice OpenAPI schema.
Tests & Factory
test/ksef_hub/invoices_test.exs, test/ksef_hub_web/controllers/api/invoice_controller_test.exs, test/ksef_hub_web/live/invoice_live/show_test.exs, test/ksef_hub_web/live/invoice_live/index_test.exs, test/support/factory.ex
Extensive test updates for access control (visibility, grant lifecycle, API and LiveView flows), adds invoice_access_grant_factory/0, and updates reviewer-related tests to use access_restricted.
Docs / ADR
docs/adr/0035-invoice-access-control.md
New ADR describing design: access_restricted flag, grants table, query filter, permission gates and API contract.

Sequence Diagram

sequenceDiagram
    actor User as User/API
    participant Ctrl as Controller / LiveView
    participant Inv as Invoices Context
    participant Auth as Authorization
    participant DB as Database

    User->>Ctrl: Request invoice(s)/action (role, api token/current_user)
    Ctrl->>Inv: list/get invoice(s) with opts {role, user_id}
    Inv->>Auth: can?(role, :view_all_invoice_types)
    Auth-->>Inv: permission result
    alt full visibility
        Inv->>DB: Query invoices (no access filter)
    else limited visibility
        Inv->>DB: Query WHERE access_restricted = false
        Inv->>DB: OR invoice.id IN (SELECT invoice_id FROM invoice_access_grants WHERE user_id = ?)
    end
    DB-->>Inv: Filtered results
    Inv-->>Ctrl: Invoice(s) (+ grants when requested)
    Ctrl-->>User: Render JSON / HTML / LiveView update
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through ledgers, gates in tow,
Placed tiny grants where reviewers go,
Income tucked safe behind a snug wall,
Owners still wander the ledger hall,
Hop — access guarded, one carrot for all.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add invoice access control for reviewers' directly summarizes the main change: introducing invoice access control functionality specifically for reviewer visibility restrictions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/invoice-access-control

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

- GET /api/invoices/:id/access — view access status and grants
- PUT /api/invoices/:id/access — toggle access_restricted flag
- POST /api/invoices/:id/access/grants — grant user access
- DELETE /api/invoices/:id/access/grants/:user_id — revoke access
- All endpoints require :manage_team permission (owner/admin only)
- Add access_restricted field to Invoice OpenAPI schema and JSON response
- Add AccessGrant and AccessGrantListResponse OpenAPI schemas
- 6 new API tests including reviewer filtering verification

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
lib/ksef_hub/invoices/invoice_access_grant.ex (1)

1-21: Consider adding a changeset for constraint handling.

The schema is correctly defined with appropriate associations. However, adding a changeset function would allow proper handling of the unique constraint violation (duplicate grant attempts) and provide a more idiomatic Ecto pattern.

♻️ Optional: Add changeset for validation
 defmodule KsefHub.Invoices.InvoiceAccessGrant do
   `@moduledoc` "Schema for access grants on restricted invoices. Links a user to an invoice they are allowed to see."

   use Ecto.Schema
+  import Ecto.Changeset

   alias KsefHub.Accounts.User
   alias KsefHub.Invoices.Invoice

   `@type` t :: %__MODULE__{}

   `@primary_key` {:id, :binary_id, autogenerate: true}
   `@foreign_key_type` :binary_id

   schema "invoice_access_grants" do
     belongs_to :invoice, Invoice
     belongs_to :user, User
     belongs_to :granted_by, User

     timestamps()
   end
+
+  `@doc` "Builds a changeset for creating an access grant."
+  `@spec` changeset(t(), map()) :: Ecto.Changeset.t()
+  def changeset(grant, attrs) do
+    grant
+    |> cast(attrs, [:invoice_id, :user_id, :granted_by_id])
+    |> validate_required([:invoice_id, :user_id])
+    |> foreign_key_constraint(:invoice_id)
+    |> foreign_key_constraint(:user_id)
+    |> unique_constraint([:invoice_id, :user_id])
+  end
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices/invoice_access_grant.ex` around lines 1 - 21, Add a
changeset function to KsefHub.Invoices.InvoiceAccessGrant that casts and
validates the association ids (:invoice_id, :user_id, :granted_by_id), enforces
required fields and adds unique_constraint for the duplicate-grant case (e.g.,
unique index on [:invoice_id, :user_id]) and foreign_key_constraint calls for
:invoice_id, :user_id and :granted_by_id so constraint violations are translated
into changeset errors instead of crashing.
lib/ksef_hub_web/live/invoice_live/show.ex (1)

88-90: Avoid loading access data when the card is hidden.

access_grants and company_reviewers are fetched on every invoice view, even when can_manage_access is false and the section never renders. Guarding those assigns keeps the read-only path from paying two unnecessary queries.

♻️ Suggested change
           can_manage_payment_requests: can_manage_payment_requests,
           can_view_payment_requests: can_view_payment_requests,
           can_manage_access: can_manage_access,
-          access_grants: Invoices.list_access_grants(invoice.id),
-          company_reviewers: list_company_reviewers(company.id),
+          access_grants: if(can_manage_access, do: Invoices.list_access_grants(invoice.id), else: []),
+          company_reviewers:
+            if(can_manage_access, do: list_company_reviewers(company.id), else: []),
           payment_status: payment_status,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/show.ex` around lines 88 - 90, The view
currently always calls Invoices.list_access_grants(invoice.id) and
list_company_reviewers(company.id) even when can_manage_access is false; change
the assigns so access_grants and company_reviewers are only fetched and assigned
when can_manage_access is true (keep them nil or omit the keys otherwise).
Locate the block building the socket assigns (where can_manage_access is set) in
invoice_live/show.ex and wrap or branch the calls to Invoices.list_access_grants
and list_company_reviewers behind that boolean so the queries run only when
can_manage_access is true.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 1442-1447: The revoke button (phx-click="revoke_access" with
phx-value-user_id={grant.user_id}) is only visible on hover; add focus-driven
visibility classes so keyboard users can see and use it—update the button's
class to include focus/focus-visible (e.g., add focus:opacity-100 and/or
focus-visible:opacity-100, matching the pattern used by the comment actions) so
it becomes fully opaque on keyboard focus while keeping the existing hover/group
behavior and aria-label intact.

In `@lib/ksef_hub/invoices.ex`:
- Around line 1805-1807: The helper full_invoice_visibility?/1 currently treats
a nil role as full access, which allows callers that omit :role or :user_id to
bypass scoping; change full_invoice_visibility?(nil) to return false (deny by
default) and ensure any logic in maybe_filter_by_access/3 (and its catch-all
clause) relies on explicit presence of opts[:role] or opts[:user_id] before
returning the unfiltered query; update checks so missing role/user_id triggers
access_restricted behavior (or the filtering path) rather than granting full
visibility.
- Around line 1731-1742: grant_access/3 currently inserts unvalidated grants;
first load the invoice and the target user (e.g., via Repo.get/Repo.get_by or
your domain fetchers) and verify the user is a reviewer and user.company_id ==
invoice.company_id before calling Repo.insert; if the checks fail, return
{:error, changeset} (build a changeset from %InvoiceAccessGrant{} and add an
appropriate error via Ecto.Changeset.add_error) so the function still returns
the declared {:ok, ...} | {:error, Ecto.Changeset.t()} shape and prevents
cross-company or non-reviewer grants.

---

Nitpick comments:
In `@lib/ksef_hub_web/live/invoice_live/show.ex`:
- Around line 88-90: The view currently always calls
Invoices.list_access_grants(invoice.id) and list_company_reviewers(company.id)
even when can_manage_access is false; change the assigns so access_grants and
company_reviewers are only fetched and assigned when can_manage_access is true
(keep them nil or omit the keys otherwise). Locate the block building the socket
assigns (where can_manage_access is set) in invoice_live/show.ex and wrap or
branch the calls to Invoices.list_access_grants and list_company_reviewers
behind that boolean so the queries run only when can_manage_access is true.

In `@lib/ksef_hub/invoices/invoice_access_grant.ex`:
- Around line 1-21: Add a changeset function to
KsefHub.Invoices.InvoiceAccessGrant that casts and validates the association ids
(:invoice_id, :user_id, :granted_by_id), enforces required fields and adds
unique_constraint for the duplicate-grant case (e.g., unique index on
[:invoice_id, :user_id]) and foreign_key_constraint calls for :invoice_id,
:user_id and :granted_by_id so constraint violations are translated into
changeset errors instead of crashing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8ded0fc3-3af8-4815-88bb-182a4c35d8cf

📥 Commits

Reviewing files that changed from the base of the PR and between 42da2e5 and 9f1ff0e.

📒 Files selected for processing (11)
  • lib/ksef_hub/invoices.ex
  • lib/ksef_hub/invoices/invoice.ex
  • lib/ksef_hub/invoices/invoice_access_grant.ex
  • lib/ksef_hub_web/controllers/api/invoice_controller.ex
  • lib/ksef_hub_web/live/invoice_live/classify.ex
  • lib/ksef_hub_web/live/invoice_live/index.ex
  • lib/ksef_hub_web/live/invoice_live/show.ex
  • priv/repo/migrations/20260324000000_add_invoice_access_control.exs
  • test/ksef_hub/invoices_test.exs
  • test/ksef_hub_web/live/invoice_live/show_test.exs
  • test/support/factory.ex

Comment thread lib/ksef_hub_web/live/invoice_live/show.ex
Comment thread lib/ksef_hub/invoices.ex Outdated
Comment thread lib/ksef_hub/invoices.ex
Comment on lines +1805 to +1807
@spec full_invoice_visibility?(Membership.role() | nil) :: boolean()
defp full_invoice_visibility?(nil), do: true
defp full_invoice_visibility?(role), do: Authorization.can?(role, :view_all_invoice_types)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Don't make omitted auth context equivalent to full access.

full_invoice_visibility?(nil) plus the catch-all maybe_filter_by_access/3 clause turn missing :role/:user_id into the unfiltered query. Any caller that forgets to thread one of those opts bypasses both reviewer scoping and access_restricted, so this authorization boundary is currently fail-open.

Also applies to: 1823-1840

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices.ex` around lines 1805 - 1807, The helper
full_invoice_visibility?/1 currently treats a nil role as full access, which
allows callers that omit :role or :user_id to bypass scoping; change
full_invoice_visibility?(nil) to return false (deny by default) and ensure any
logic in maybe_filter_by_access/3 (and its catch-all clause) relies on explicit
presence of opts[:role] or opts[:user_id] before returning the unfiltered query;
update checks so missing role/user_id triggers access_restricted behavior (or
the filtering path) rather than granting full visibility.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/ksef_hub_web/controllers/api/invoice_controller_test.exs (1)

1492-1518: Add malformed user_id tests for grant/revoke.

Please add negative tests for invalid UUID inputs (e.g., "not-a-uuid") so API error semantics are pinned for both endpoints.

🧪 Suggested test additions
+    test "grant_access returns 422 for invalid user_id", %{conn: conn} do
+      %{company: company, token: token} = create_user_with_token(:owner)
+      invoice = insert(:invoice, company: company, access_restricted: true)
+
+      conn =
+        conn
+        |> api_conn(token)
+        |> post("/api/invoices/#{invoice.id}/access/grants", %{user_id: "not-a-uuid"})
+
+      assert conn.status == 422
+    end
+
+    test "revoke_access returns 422 for invalid user_id", %{conn: conn} do
+      %{company: company, token: token} = create_user_with_token(:owner)
+      invoice = insert(:invoice, company: company, access_restricted: true)
+
+      conn =
+        conn
+        |> api_conn(token)
+        |> delete("/api/invoices/#{invoice.id}/access/grants/not-a-uuid")
+
+      assert conn.status == 422
+    end

Also applies to: 1520-1526

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/controllers/api/invoice_controller_test.exs` around lines
1492 - 1518, Add negative tests for malformed UUIDs covering both grant and
revoke endpoints: create a test (e.g., "grant_access and revoke_access reject
malformed user_id") that sets up company/token and an access_restricted invoice
like the existing "grant_access and revoke_access work" test, then call POST
"/api/invoices/#{invoice.id}/access/grants" with %{user_id: "not-a-uuid"} and
assert the response is a 400 (or the API's validation status) and contains an
error about invalid UUID, and similarly call DELETE
"/api/invoices/#{invoice.id}/access/grants/not-a-uuid" and assert the same 400 +
validation error; use the same helpers (api_conn, create_user_with_token,
insert) to locate where to add the tests near the existing test for
grants/revokes so the behavior is pinned.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/controllers/api/invoice_controller.ex`:
- Around line 926-933: The OpenAPI responses map for the set_access operation
(in lib/ksef_hub_web/controllers/api/invoice_controller.ex) is missing the 422
entry that the controller actually returns; update the responses map used by the
set_access operation to include a 422 response (e.g., {"Unprocessable Entity —
validation failed", "application/json", Schemas.ErrorResponse}) so the API
contract matches the actual returns. Locate the responses map near the
set_access operation and add the 422 key with a clear message and the
appropriate schema (Schemas.ErrorResponse) to cover the validation/error cases
referenced on the other lines.

---

Nitpick comments:
In `@test/ksef_hub_web/controllers/api/invoice_controller_test.exs`:
- Around line 1492-1518: Add negative tests for malformed UUIDs covering both
grant and revoke endpoints: create a test (e.g., "grant_access and revoke_access
reject malformed user_id") that sets up company/token and an access_restricted
invoice like the existing "grant_access and revoke_access work" test, then call
POST "/api/invoices/#{invoice.id}/access/grants" with %{user_id: "not-a-uuid"}
and assert the response is a 400 (or the API's validation status) and contains
an error about invalid UUID, and similarly call DELETE
"/api/invoices/#{invoice.id}/access/grants/not-a-uuid" and assert the same 400 +
validation error; use the same helpers (api_conn, create_user_with_token,
insert) to locate where to add the tests near the existing test for
grants/revokes so the behavior is pinned.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5c4a0f43-1ceb-4e96-9325-df10cbea0192

📥 Commits

Reviewing files that changed from the base of the PR and between 9f1ff0e and 8065630.

📒 Files selected for processing (6)
  • lib/ksef_hub_web/controllers/api/invoice_controller.ex
  • lib/ksef_hub_web/router.ex
  • lib/ksef_hub_web/schemas/access_grant.ex
  • lib/ksef_hub_web/schemas/access_grant_list_response.ex
  • lib/ksef_hub_web/schemas/invoice.ex
  • test/ksef_hub_web/controllers/api/invoice_controller_test.exs
✅ Files skipped from review due to trivial changes (3)
  • lib/ksef_hub_web/schemas/invoice.ex
  • lib/ksef_hub_web/schemas/access_grant_list_response.ex
  • lib/ksef_hub_web/schemas/access_grant.ex

Comment thread lib/ksef_hub_web/controllers/api/invoice_controller.ex
Review fixes:
- Revoke button: add focus:opacity-100 focus-visible:opacity-100 for
  keyboard accessibility (matching comment actions pattern)
- maybe_filter_by_access catch-all: deny restricted invoices when role
  is present but user_id is missing (safety net for forgotten opts)
- grant_access: validate target user is a company member with a
  non-privileged role before inserting; reject cross-company and
  admin/owner/accountant grants with changeset errors
- InvoiceAccessGrant: add changeset/2 with cast, validate_required,
  unique_constraint and foreign_key_constraint
- Lazy-load access_grants/company_reviewers only when can_manage_access
- set_access OpenAPI: add missing 422 response
- API grant/revoke: validate UUID format before passing to context
- Tests: add grant validation tests (non-member, full-visibility role,
  malformed UUID), update existing CRUD tests to use proper memberships

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
lib/ksef_hub/invoices.ex (1)

1847-1886: Consider documenting the authorization bypass for nil role.

The current design treats nil role as a system/internal caller with full access (line 1848, 1883). While a safety net exists for cases where role is present but user_id is missing (line 1885-1886), callers that omit both parameters bypass access filtering entirely.

This appears intentional for background jobs and internal operations, but the implicit trust model should be documented. Consider adding a comment explaining when nil role is expected and warning that omitting both opts bypasses authorization.

+ # NOTE: nil role indicates an internal/system call (e.g., background jobs, sync)
+ # and bypasses access filtering. External callers (API, LiveView) must always
+ # provide role and user_id to enforce access restrictions.
  `@spec` full_invoice_visibility?(Membership.role() | nil) :: boolean()
  defp full_invoice_visibility?(nil), do: true
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices.ex` around lines 1847 - 1886, Add an explicit comment
documenting the authorization bypass for a nil role: explain that
full_invoice_visibility?/1 treats nil as an internal/system caller with full
access and that maybe_filter_by_access/3, scope_by_role/2, and
maybe_scope_type_by_role/2 therefore do no filtering for nil; state the intended
callers (e.g., background jobs, system processes), and warn that omitting both
role and user_id will bypass access checks so callers should pass a non-nil
role/user_id for external requests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub/invoices/invoice_access_grant.ex`:
- Around line 23-33: Remove the unused changeset/2 function from the
InvoiceAccessGrant module: delete the entire def changeset(grant, attrs) block
so programmatically-set fields (invoice_id, user_id, granted_by_id) are not
exposed via cast; search for any callers of changeset/2 and replace creation
code to explicitly set those fields when building the struct or use a safe
changeset that only casts permitted public attributes if you actually need a
changeset helper in the future.

---

Nitpick comments:
In `@lib/ksef_hub/invoices.ex`:
- Around line 1847-1886: Add an explicit comment documenting the authorization
bypass for a nil role: explain that full_invoice_visibility?/1 treats nil as an
internal/system caller with full access and that maybe_filter_by_access/3,
scope_by_role/2, and maybe_scope_type_by_role/2 therefore do no filtering for
nil; state the intended callers (e.g., background jobs, system processes), and
warn that omitting both role and user_id will bypass access checks so callers
should pass a non-nil role/user_id for external requests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 068d772c-4cc1-42e3-87de-5b40b5031c53

📥 Commits

Reviewing files that changed from the base of the PR and between 8065630 and 69e4e79.

📒 Files selected for processing (6)
  • lib/ksef_hub/invoices.ex
  • lib/ksef_hub/invoices/invoice_access_grant.ex
  • lib/ksef_hub_web/controllers/api/invoice_controller.ex
  • lib/ksef_hub_web/live/invoice_live/show.ex
  • test/ksef_hub/invoices_test.exs
  • test/ksef_hub_web/controllers/api/invoice_controller_test.exs
✅ Files skipped from review due to trivial changes (2)
  • test/ksef_hub_web/controllers/api/invoice_controller_test.exs
  • test/ksef_hub/invoices_test.exs

Comment on lines +23 to +33
@doc "Builds a changeset for creating an access grant."
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(grant, attrs) do
grant
|> cast(attrs, [:invoice_id, :user_id, :granted_by_id])
|> validate_required([:invoice_id, :user_id])
|> unique_constraint([:invoice_id, :user_id])
|> foreign_key_constraint(:invoice_id)
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:granted_by_id)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if InvoiceAccessGrant.changeset/2 is actually called anywhere
rg -n "InvoiceAccessGrant\.changeset" --type=elixir
rg -n "InvoiceAccessGrant\)\.changeset" --type=elixir

Repository: appunite/appunite-ksef-ex

Length of output: 51


Remove the unused changeset/2 function.

The function is not called anywhere in the codebase. Additionally, if it were used, it would violate the coding guideline that programmatically-set fields like user_id, invoice_id, and granted_by_id must not be listed in cast calls — these should be explicitly set when creating the struct instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices/invoice_access_grant.ex` around lines 23 - 33, Remove
the unused changeset/2 function from the InvoiceAccessGrant module: delete the
entire def changeset(grant, attrs) block so programmatically-set fields
(invoice_id, user_id, granted_by_id) are not exposed via cast; search for any
callers of changeset/2 and replace creation code to explicitly set those fields
when building the struct or use a safe changeset that only casts permitted
public attributes if you actually need a changeset helper in the future.

Replace hardcoded type-based scoping (reviewers forced to expense only)
with the access control system. Income invoices are now automatically
created with access_restricted: true, so reviewers can't see them unless
explicitly granted access — same mechanism as restricted expense invoices.

- Remove scope_by_role and maybe_scope_type_by_role from Invoices context
- Auto-set access_restricted: true on income invoice creation/upsert
- Pass access_restricted through trusted_fields in do_insert/do_upsert
- Migration: set access_restricted=true on all existing income invoices
- Reviewers now see both Expense/Income tabs (empty income is expected)
- Remove sanitize_type from LiveView index (access control handles it)
- Update all tests to use access_restricted: true on income test fixtures
- Fix credo issues: convert with-single-clause to case in API controller

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
lib/ksef_hub/invoices.ex (1)

1861-1886: ⚠️ Potential issue | 🟠 Major

Authorization defaults remain fail-open for missing role.

full_invoice_visibility?(nil) returning true and the maybe_filter_by_access(query, nil, _user_id) clause returning an unfiltered query means that any caller omitting :role bypasses access filtering entirely.

While the comment indicates this is intentional for "internal/system calls," this design creates risk if an external entry point (controller/LiveView) inadvertently omits the role option. The safety net on lines 1885-1886 only catches the case where role is present but user_id is missing.

Consider adding compile-time or runtime validation in controller/LiveView entry points to ensure role is always passed, or inverting the default to deny access when role is nil.

#!/bin/bash
# Verify all callers of get_invoice*/list_invoices* pass role: option

echo "=== Checking API controller calls ==="
rg -n "Invoices\.(get_invoice|list_invoices)" lib/ksef_hub_web/controllers/api/ -A 3 | head -60

echo ""
echo "=== Checking LiveView calls ==="
rg -n "Invoices\.(get_invoice|list_invoices)" lib/ksef_hub_web/live/ -A 3 | head -60

echo ""
echo "=== Checking other callers that might omit role ==="
rg -n "Invoices\.(get_invoice|list_invoices)" lib/ksef_hub/ --glob '!lib/ksef_hub/invoices.ex' -A 3 | head -60
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices.ex` around lines 1861 - 1886,
full_invoice_visibility?(nil) and the current maybe_filter_by_access/3 clause
for nil role are leaving access open when callers omit role; change the default
to deny rather than allow by updating full_invoice_visibility?(nil) to return
false (or remove the nil clause) and make the maybe_filter_by_access(query, nil,
_user_id) clause apply the restricted-invoice filter (same as the _role clause),
ensuring callers that omit role cannot bypass checks; alternatively, if you want
to keep a special internal/system path, add an explicit internal API (e.g.,
maybe_filter_by_access_internal/3) and require callers to use it so public
functions Invoices.get_invoice*/list_invoices* must always pass role.
🧹 Nitpick comments (3)
lib/ksef_hub_web/live/invoice_live/index.ex (1)

92-92: Remove the leftover role-based wrapper.

can_view_all_types is hard-coded now and normalize_filters/2 no longer transforms anything, so the old fallback path is dead code. Inlining filters here and dropping the unused wrapper will make the access-control flow easier to follow.

Also applies to: 100-101

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/live/invoice_live/index.ex` at line 92, The role-based
wrapper around setting filters is now dead: remove the wrapper that calls
normalize_filters/2 and directly inline the hard-coded filters assignment where
can_view_all_types: true is currently set; delete the unused normalize_filters/2
fallback/conditional path and any variables only used for that branch so the
access-control flow uses the direct filters value (update occurrences referenced
around the same block and the related lines noted at 100-101).
test/ksef_hub_web/live/invoice_live/show_test.exs (1)

1009-1015: Assert the access card by selector, not by raw HTML text.

These checks will break on copy or markup churn and don't prove that the specific card/menu is present or absent. Prefer has_element?/2 against the card/menu selector you added for this UI.

Based on learnings "Never test against raw HTML - always use element/2, has_element?/2, and similar functions with selectors like assert has_element?(view, \"#my-form\")."

Also applies to: 1017-1035

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/invoice_live/show_test.exs` around lines 1009 - 1015,
Replace brittle raw-HTML assertions in the test "access control card is visible
for owner" by using the LiveView helpers: capture the returned view from live/2
(use the second element from {:ok, view, _html}) and assert presence of the
access card via has_element?/2 or element/2 with the specific selector you added
(e.g. "#access-card" or ".access-control-card"); do the same replacement for the
related assertions in the block covering lines 1017-1035 so tests assert
has_element?(view, "YOUR_SELECTOR") instead of html =~ "Access" / "All
reviewers".
test/ksef_hub_web/live/invoice_live/index_test.exs (1)

347-366: This case still only proves the default expense tab.

The view defaults to type=expense, so the restricted income invoice never reaches the query/render path here. If the intent is to cover the new access-control behavior, drive the test with ?type=income or add an unrestricted income control and assert only the restricted record is filtered.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/live/invoice_live/index_test.exs` around lines 347 - 366,
The test currently relies on the default expense tab so the access-restricted
income never reaches the view; update the test to drive the income view
explicitly by calling live with the income query param (replace live(conn,
~p"/c/#{company.id}/invoices") with live(conn,
~p"/c/#{company.id}/invoices?type=income")) and insert an additional
unrestricted income invoice (e.g. insert(:invoice, type: :income, seller_name:
"Visible Income Seller", company: company, access_restricted: false)) so you can
assert the restricted income ("Hidden Income Seller") is filtered out while the
unrestricted income is shown.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/controllers/api/invoice_controller.ex`:
- Around line 1011-1045: The grant_access/2 clause only matches when user_id is
a binary and will raise FunctionClauseError if missing or non-string; add a
fallback clause named grant_access/2 that matches conn and params (or pattern
with missing or non-binary "user_id") similar to set_access, and return a
guarded JSON error response (e.g., put_status(:unprocessable_entity) or
:bad_request and json(%{error: "Invalid or missing user_id"})) to safely handle
invalid inputs instead of crashing; update the grant_access function set to
include the existing binary user_id clause plus this new fallback clause so all
request shapes are handled.

In `@test/ksef_hub/invoices_test.exs`:
- Around line 600-605: The test currently uses role: nil which creates a hidden
backdoor — change the test "role: nil returns all invoices (backward compat)" so
it supplies an explicit auth context instead of nil; call
Invoices.list_invoices_paginated(company.id, %{}, role: :admin) (or an
appropriate explicit role/user_id) and assert total_count == 2, or alternatively
update the test to expect restricted results when no role is provided; this
ensures the code path in Invoices.list_invoices_paginated is exercised with an
explicit auth context rather than relying on a nil bypass.

---

Duplicate comments:
In `@lib/ksef_hub/invoices.ex`:
- Around line 1861-1886: full_invoice_visibility?(nil) and the current
maybe_filter_by_access/3 clause for nil role are leaving access open when
callers omit role; change the default to deny rather than allow by updating
full_invoice_visibility?(nil) to return false (or remove the nil clause) and
make the maybe_filter_by_access(query, nil, _user_id) clause apply the
restricted-invoice filter (same as the _role clause), ensuring callers that omit
role cannot bypass checks; alternatively, if you want to keep a special
internal/system path, add an explicit internal API (e.g.,
maybe_filter_by_access_internal/3) and require callers to use it so public
functions Invoices.get_invoice*/list_invoices* must always pass role.

---

Nitpick comments:
In `@lib/ksef_hub_web/live/invoice_live/index.ex`:
- Line 92: The role-based wrapper around setting filters is now dead: remove the
wrapper that calls normalize_filters/2 and directly inline the hard-coded
filters assignment where can_view_all_types: true is currently set; delete the
unused normalize_filters/2 fallback/conditional path and any variables only used
for that branch so the access-control flow uses the direct filters value (update
occurrences referenced around the same block and the related lines noted at
100-101).

In `@test/ksef_hub_web/live/invoice_live/index_test.exs`:
- Around line 347-366: The test currently relies on the default expense tab so
the access-restricted income never reaches the view; update the test to drive
the income view explicitly by calling live with the income query param (replace
live(conn, ~p"/c/#{company.id}/invoices") with live(conn,
~p"/c/#{company.id}/invoices?type=income")) and insert an additional
unrestricted income invoice (e.g. insert(:invoice, type: :income, seller_name:
"Visible Income Seller", company: company, access_restricted: false)) so you can
assert the restricted income ("Hidden Income Seller") is filtered out while the
unrestricted income is shown.

In `@test/ksef_hub_web/live/invoice_live/show_test.exs`:
- Around line 1009-1015: Replace brittle raw-HTML assertions in the test "access
control card is visible for owner" by using the LiveView helpers: capture the
returned view from live/2 (use the second element from {:ok, view, _html}) and
assert presence of the access card via has_element?/2 or element/2 with the
specific selector you added (e.g. "#access-card" or ".access-control-card"); do
the same replacement for the related assertions in the block covering lines
1017-1035 so tests assert has_element?(view, "YOUR_SELECTOR") instead of html =~
"Access" / "All reviewers".

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: da32dc49-776c-445e-9ecb-6c40b9e6b772

📥 Commits

Reviewing files that changed from the base of the PR and between 69e4e79 and 36c63ac.

📒 Files selected for processing (8)
  • lib/ksef_hub/invoices.ex
  • lib/ksef_hub_web/controllers/api/invoice_controller.ex
  • lib/ksef_hub_web/live/invoice_live/index.ex
  • priv/repo/migrations/20260324064116_restrict_income_invoices.exs
  • test/ksef_hub/invoices_test.exs
  • test/ksef_hub_web/controllers/api/invoice_controller_test.exs
  • test/ksef_hub_web/live/invoice_live/index_test.exs
  • test/ksef_hub_web/live/invoice_live/show_test.exs
✅ Files skipped from review due to trivial changes (1)
  • priv/repo/migrations/20260324064116_restrict_income_invoices.exs

Comment thread lib/ksef_hub_web/controllers/api/invoice_controller.ex
Comment thread test/ksef_hub/invoices_test.exs Outdated
Comment on lines 600 to 605
test "role: nil returns all invoices (backward compat)", %{company: company} do
insert(:invoice, type: :income, company: company)
insert(:invoice, type: :expense, company: company)

result = Invoices.list_invoices_paginated(company.id, %{}, role: nil)

assert result.total_count == 2

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't keep role: nil as an unrestricted backdoor.

This test bakes in “missing auth context == full visibility”. The context APIs still default opts to [], so any caller that forgets to pass role/user_id will silently bypass the new restriction model and expose restricted invoices. Safer behavior is to require explicit auth context or default the nil-role path to the restricted view instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub/invoices_test.exs` around lines 600 - 605, The test currently
uses role: nil which creates a hidden backdoor — change the test "role: nil
returns all invoices (backward compat)" so it supplies an explicit auth context
instead of nil; call Invoices.list_invoices_paginated(company.id, %{}, role:
:admin) (or an appropriate explicit role/user_id) and assert total_count == 2,
or alternatively update the test to expect restricted results when no role is
provided; this ensures the code path in Invoices.list_invoices_paginated is
exercised with an explicit auth context rather than relying on a nil bypass.

Security fixes:
- invoice_pdf_controller: add missing user_id to access filtering
  (reviewer could download restricted invoice PDFs)
- payment_request_live/form: add role/user_id to invoice lookup
  (reviewer could prefill payment from restricted invoice)
- Prevent unrestricting income invoices (set_access_restricted rejects
  income + false to maintain invariant that income is always restricted)

New tests:
- Income invoice cannot be unrestricted
- Expense invoice can be toggled freely
- Grant to non-member returns error
- Grant to admin returns error (already has full access)
- Upsert auto-restricts income invoices
- API: cannot unrestrict income invoice (422)

Documentation:
- ADR 0035: Invoice Access Control architecture decision record

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
lib/ksef_hub_web/controllers/api/invoice_controller.ex (1)

1016-1050: ⚠️ Potential issue | 🟠 Major

Add the missing fallback for invalid grant_access payloads.

This action still only matches when user_id is present and binary. A missing or non-string payload will raise FunctionClauseError instead of returning the documented 422.

🛡️ Proposed fallback
   def grant_access(conn, %{"id" => id, "user_id" => user_id}) when is_binary(user_id) do
     case Ecto.UUID.cast(user_id) do
       {:ok, _} ->
         company_id = conn.assigns.current_company.id
         granted_by_id = conn.assigns.api_token.created_by_id

         invoice =
           Invoices.get_invoice!(company_id, id,
             role: conn.assigns[:current_role],
             user_id: granted_by_id
           )

         case Invoices.grant_access(invoice.id, user_id, granted_by_id) do
           {:ok, _grant} ->
             grants = Invoices.list_access_grants(invoice.id)

             json(conn, %{
               data: %{
                 access_restricted: invoice.access_restricted,
                 grants: Enum.map(grants, &access_grant_json/1)
               }
             })

           {:error, _} ->
             conn
             |> put_status(:unprocessable_entity)
             |> json(%{error: "Failed to grant access"})
         end

       :error ->
         conn
         |> put_status(:unprocessable_entity)
         |> json(%{error: "Invalid user_id format"})
     end
   end
+
+  def grant_access(conn, _params) do
+    conn
+    |> put_status(:unprocessable_entity)
+    |> json(%{error: "user_id (UUID string) is required"})
+  end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/controllers/api/invoice_controller.ex` around lines 1016 -
1050, The grant_access action currently only has a clause def grant_access(conn,
%{"id" => id, "user_id" => user_id}) when is_binary(user_id) and will raise
FunctionClauseError for missing or non-string payloads; add a fallback clause
(e.g., def grant_access(conn, _params) or def grant_access(conn, %{"id" => _id}
= _params) to match requests without a binary "user_id") that returns
put_status(:unprocessable_entity) and json(%{error: "Invalid or missing
user_id"}) so invalid payloads consistently respond with 422 instead of
crashing; update any tests or docs referencing grant_access behavior as needed.
🧹 Nitpick comments (1)
test/ksef_hub_web/controllers/api/invoice_controller_test.exs (1)

1465-1583: Add one API allow-path test for explicit grants.

The new suite proves deny-paths and grant CRUD, but it never verifies that a reviewer with an explicit grant can actually GET /api/invoices/:id (and ideally see it in index). That end-to-end assertion is the controller wiring this PR changes, so it's worth covering here. Based on learnings: Test controller actions and context interactions using integration tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/controllers/api/invoice_controller_test.exs` around lines
1465 - 1583, Add an integration test that verifies a reviewer with an explicit
grant can view a restricted invoice via GET /api/invoices/:id and see it in GET
/api/invoices: create owner and reviewer users with create_user_with_token,
insert an access_restricted invoice (insert(:invoice, access_restricted: true)),
add the reviewer membership (insert(:membership, user: reviewer, company:
company, role: :reviewer)), grant access by POSTing to
"/api/invoices/#{invoice.id}/access/grants" using api_conn(token) for the owner
and then call GET "/api/invoices/#{invoice.id}" with the reviewer token to
assert status 200 and returned invoice id matches; also call GET "/api/invoices"
with reviewer token and assert the invoice appears in the returned data and
meta.total_count > 0. Ensure you use the same helper names
(create_user_with_token, api_conn, insert) and the existing grants endpoints
used in tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/controllers/api/invoice_controller.ex`:
- Around line 1068-1074: Update the OpenApi operation spec for revoke_access/2
in InvoiceController to include a 422 response entry: locate the operation
definition (the responses map for revoke_access/2) and add a 422 =>
{"Unprocessable Entity — invalid request parameters (e.g. invalid UUID)",
"application/json", Schemas.ErrorResponse} so generated clients/docs match the
runtime behavior; apply the same 422 addition to the other revoke_access spec
occurrence noted around the 1107-1110 region and ensure the controller still
uses OpenApiSpex.ControllerSpecs.

---

Duplicate comments:
In `@lib/ksef_hub_web/controllers/api/invoice_controller.ex`:
- Around line 1016-1050: The grant_access action currently only has a clause def
grant_access(conn, %{"id" => id, "user_id" => user_id}) when is_binary(user_id)
and will raise FunctionClauseError for missing or non-string payloads; add a
fallback clause (e.g., def grant_access(conn, _params) or def grant_access(conn,
%{"id" => _id} = _params) to match requests without a binary "user_id") that
returns put_status(:unprocessable_entity) and json(%{error: "Invalid or missing
user_id"}) so invalid payloads consistently respond with 422 instead of
crashing; update any tests or docs referencing grant_access behavior as needed.

---

Nitpick comments:
In `@test/ksef_hub_web/controllers/api/invoice_controller_test.exs`:
- Around line 1465-1583: Add an integration test that verifies a reviewer with
an explicit grant can view a restricted invoice via GET /api/invoices/:id and
see it in GET /api/invoices: create owner and reviewer users with
create_user_with_token, insert an access_restricted invoice (insert(:invoice,
access_restricted: true)), add the reviewer membership (insert(:membership,
user: reviewer, company: company, role: :reviewer)), grant access by POSTing to
"/api/invoices/#{invoice.id}/access/grants" using api_conn(token) for the owner
and then call GET "/api/invoices/#{invoice.id}" with the reviewer token to
assert status 200 and returned invoice id matches; also call GET "/api/invoices"
with reviewer token and assert the invoice appears in the returned data and
meta.total_count > 0. Ensure you use the same helper names
(create_user_with_token, api_conn, insert) and the existing grants endpoints
used in tests.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 85057935-7992-4d86-9c07-a65c31d8a0cc

📥 Commits

Reviewing files that changed from the base of the PR and between 36c63ac and 7da7855.

📒 Files selected for processing (8)
  • docs/adr/0035-invoice-access-control.md
  • lib/ksef_hub/invoices.ex
  • lib/ksef_hub_web/controllers/api/invoice_controller.ex
  • lib/ksef_hub_web/controllers/invoice_pdf_controller.ex
  • lib/ksef_hub_web/live/invoice_live/show.ex
  • lib/ksef_hub_web/live/payment_request_live/form.ex
  • test/ksef_hub/invoices_test.exs
  • test/ksef_hub_web/controllers/api/invoice_controller_test.exs
✅ Files skipped from review due to trivial changes (2)
  • docs/adr/0035-invoice-access-control.md
  • test/ksef_hub/invoices_test.exs

Comment thread lib/ksef_hub_web/controllers/api/invoice_controller.ex
Comment on lines +612 to +623
def handle_event("grant_access", %{"user_id" => user_id}, socket) do
invoice = socket.assigns.invoice
granted_by_id = socket.assigns.current_user.id

case Invoices.grant_access(invoice.id, user_id, granted_by_id) do
{:ok, _grant} ->
{:noreply, assign(socket, access_grants: Invoices.list_access_grants(invoice.id))}

{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to grant access.")}
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard user_id in the access events.

Both handlers trust a client-supplied user_id. A tampered event with a missing or malformed value can miss the function head or raise before you ever reach the flash path, which kills the LiveView instead of returning a recoverable error.

🛡️ Proposed hardening
-  def handle_event("grant_access", %{"user_id" => user_id}, socket) do
+  def handle_event("grant_access", %{"user_id" => user_id}, socket) when is_binary(user_id) do
     invoice = socket.assigns.invoice
     granted_by_id = socket.assigns.current_user.id

-    case Invoices.grant_access(invoice.id, user_id, granted_by_id) do
-      {:ok, _grant} ->
-        {:noreply, assign(socket, access_grants: Invoices.list_access_grants(invoice.id))}
-
-      {:error, _} ->
-        {:noreply, put_flash(socket, :error, "Failed to grant access.")}
+    with {:ok, _} <- Ecto.UUID.cast(user_id),
+         {:ok, _grant} <- Invoices.grant_access(invoice.id, user_id, granted_by_id) do
+      {:noreply, assign(socket, access_grants: Invoices.list_access_grants(invoice.id))}
+    else
+      :error ->
+        {:noreply, put_flash(socket, :error, "Invalid reviewer id.")}
+
+      {:error, _} ->
+        {:noreply, put_flash(socket, :error, "Failed to grant access.")}
     end
   end
+
+  def handle_event("grant_access", _params, socket),
+    do: {:noreply, put_flash(socket, :error, "Invalid reviewer id.")}
 
-  def handle_event("revoke_access", %{"user_id" => user_id}, socket) do
+  def handle_event("revoke_access", %{"user_id" => user_id}, socket) when is_binary(user_id) do
     invoice = socket.assigns.invoice

-    case Invoices.revoke_access(invoice.id, user_id) do
-      {:ok, _} ->
-        {:noreply, assign(socket, access_grants: Invoices.list_access_grants(invoice.id))}
-
-      {:error, _} ->
-        {:noreply, put_flash(socket, :error, "Failed to revoke access.")}
+    with {:ok, _} <- Ecto.UUID.cast(user_id),
+         {:ok, _} <- Invoices.revoke_access(invoice.id, user_id) do
+      {:noreply, assign(socket, access_grants: Invoices.list_access_grants(invoice.id))}
+    else
+      :error ->
+        {:noreply, put_flash(socket, :error, "Invalid reviewer id.")}
+
+      {:error, _} ->
+        {:noreply, put_flash(socket, :error, "Failed to revoke access.")}
     end
   end
+
+  def handle_event("revoke_access", _params, socket),
+    do: {:noreply, put_flash(socket, :error, "Invalid reviewer id.")}

Also applies to: 630-639

- Remove dead `can_view_all_types` assign (always true) and template
  conditionals from invoice index — both tabs now render unconditionally
- Simplify `maybe_filter_by_access` to accept opts keyword list directly
  with safety-net fallback for missing user_id (deny restricted)
- Change `full_invoice_visibility?(nil)` to return false (fail-closed)
- Add grant_access fallback clause for missing user_id in API controller
- Improve test coverage: reviewer-with-grant e2e API test,
  reviewer access filtering in index LiveView test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek merged commit 66d95f8 into main Mar 24, 2026
3 checks passed
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.

1 participant