Skip to content

kernel(tenancy): activity gets legal_entity_id and the RESTRICTIVE le_scope policy (#1716) - #1727

Merged
JOY (JOY) merged 4 commits into
mainfrom
claude/activity-le-1716
Oct 28, 2026
Merged

JOY (JOY) merged 4 commits into
mainfrom
claude/activity-le-1716

Conversation

@JOY

@JOY JOY (JOY) commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1716

A member limited to one legal entity could read another entity's activity rows, such as voucher codes, amounts and descriptions, through the proposal timeline (kernel.proposal.timeline) and the org overview. This PR scopes crove_kernel.activity by legal entity.

What changes

  • Migration 20261103120000_crove_kernel_activity_legal_entity.sql.
    • activity gets a nullable legal_entity_id, with the foreign key (org_id, legal_entity_id) to legal_entity and the index (legal_entity_id, org_id) (G3).
    • The RESTRICTIVE policy le_scope is added. NULL means an org-level row that every member reads. The predicate is the same as role_assignment_scope.
  • Trigger b_legal_entity (crove_kernel.activity_legal_entity_fill, BEFORE INSERT, ENABLE ALWAYS, SECURITY INVOKER). Its arguments come in fours: record type, table, record-id column, entity column. A row of a listed type takes its record's entity, read as the writer.
    • The listed types are finance.cash_voucher, journal_voucher, fixed_asset, journal_entry, purchase_document, portal_invoice_list, finance.bank_account (via bank_line.import_id) and kernel.legal_entity (via its own id).
    • If the writer cannot read the record, the row takes the writer's own entity when the writer is limited to exactly one entity. Otherwise it stays org-level.
    • If the writer names an entity other than the record's, the insert is refused with LEGAL_ENTITY_MISMATCH.
    • packages/kernel-runtime/src is untouched.
  • Backfill. Existing rows of the listed types take their record's entity through an idempotent UPDATE ... FROM join, which only touches rows that are still NULL. Rows whose record is gone stay org-level.
  • Tests.
    • The RLS matrix (invariant) now requires every table with a nullable legal_entity_id to be either a listed scope (NULLABLE_ENTITY_SCOPED: activity) or a reasoned exception (proposal until Legal-entity scope for agents' positions, then proposals per legal entity (#1280) #1538, role_assignment). For activity it checks the exact policy and probes the scoped member: no row of another entity, every org-level row, an insert naming another entity refused with 42501, an org-level insert accepted.
    • The new apps/kernel/test/activity-legal-entity.db.test.ts covers the backfill, the trigger, the timeline and the overview for a member scoped to A versus one scoped to B, direct reads, the mismatch and fallback rules, and new entities.
    • The new unit test apps/kernel/test/activity-record-types.test.ts reads every ctx.activity( record type in apps, modules and packages. Each must be either in the trigger's list or org-level with a stated reason, and computed types must be explained.
  • Other files. pnpm db:types output (packages/db/src/generated/database.ts), the kernel manifest column, the timeline doc comment, and plan §4.6.

What changes for agents

Agents have an empty le_scope (#1538). No agent query or tool reads activity; only the user-facing timeline and overview do. Agents write kernel.proposal rows (proposal.created and proposal.approved) and the T18 test rows. Those types are not listed, so the rows stay NULL and pass le_scope's USING and WITH CHECK. Agent approvals run in the system context, whose scope is *. Nothing changes for agents, and no agent flow breaks. kernel.proposal stays unlisted on purpose: tagging it would make agents' proposal rows fail WITH CHECK.

Acceptance

  • What a user sees: a member limited to entity A (for example an external accountant) no longer sees entity B's voucher, journal entry, purchase document, asset, bank import, portal list or entity rows in a proposal's timeline, in the overview's last-activity time, or in the database. Proposal, party, member and org-settings rows still show to every member.
  • "A member scoped to A reads no activity row of B (db and invariant test); org-level rows stay visible":
    • activity-legal-entity.db.test.ts: timeline, direct counts and overview for A, B and the owner, the legacy backfilled row, and new entities.
    • rls-matrix.db.test.ts: the new nullable-scope case.
    • Evidence comes from the CI db and invariant jobs on this head. See "Tests run" below.
  • "The agents invariant stays green": agents.db.test.ts is unchanged, and it runs in CI (base copy and PR copy).
  • Review results: Gemini inline (ORDER BY tie-breaker) taken in a184b68 and resolved.
    • Codex CLI review (codex review --base origin/main): no findings.
    • Fresh-context reviewer: one blocking finding, the kernel manifest missing the column (manifest.db.test.ts), which is fixed.
    • Should-fix findings, all taken:
    • Nits taken: the overview assertion is stronger, and the matrix header, the timeline comment and the bank-import note are fixed.
  • Risk left:

Tests run

  • Local (Windows): pnpm lint, pnpm typecheck, pnpm docs:check and node tools/ci/migration-lint.mjs on the file passed.
  • Local pnpm test: 404 files passed. These failed on Windows only and are untouched by this PR: module-screens, extract network isolation, route-coverage and status-page (STATUS_FILE path config), and invariant-summary (paths).
  • Database tests were not run locally. This host has no Docker and no PostgreSQL. The db, invariant and db:types checks are verified by CI on this head.
  • The generated types were edited by hand, in the form Kanel produces for this column. CI's db:types diff confirms it.

Security review (session's own, protected paths: the migration and the invariant suite)

  • Tenancy. The policy is RESTRICTIVE beside tenant_isolation, so it only narrows. NULL rows were already readable org-wide before this PR. No existing control is loosened.
  • Trigger. It runs SECURITY INVOKER with search_path = pg_catalog, pg_temp and EXECUTE revoked from every role. It reads the record through the writer's own policies, so it reveals nothing the writer could not already read.
    • LEGAL_ENTITY_MISMATCH fires only when the writer can read the record, so it does not reveal anything either.
    • The single-entity fallback only narrows a row to the writer's own entity.
    • Only migrations set the trigger arguments, and they are quoted with %L, %I and ::regclass.
  • Writes. WITH CHECK refuses a row that names an entity outside the writer's scope. The foreign key keeps the entity inside the row's org.
  • Agents. Unchanged (see above). Approval, the ledger and the sealed context are untouched.
  • Backfill. It runs as the table owner, is deterministic, and only touches NULL rows. activity is append-only for tenants and exempt from agent_write_guard.
  • Invariant suite. The RLS matrix only gains checks. The base copy still passes against this code: activity's column is nullable, so the base matrix's NOT NULL entity check does not apply.

🤖 Generated with Claude Code

JOY (JOY) and others added 2 commits October 28, 2026 16:47
…_scope policy (#1716)

A member limited to one legal entity read another entity's activity rows
(voucher codes, amounts) through the proposal timeline and the overview.
crove_kernel.activity now carries a nullable legal_entity_id, filled by the
BEFORE INSERT trigger b_legal_entity from the record for entity-scoped record
types (vouchers, journal entries, purchase documents, fixed assets, portal
invoice lists, bank statement imports), backfilled for existing rows, and the
RESTRICTIVE le_scope policy that keeps org-level (NULL) rows visible.
Proposal rows stay org-level until #1538, so agents keep writing theirs.

The RLS matrix checks every nullable legal_entity_id is a listed scope or a
listed exception, and probes activity's entity scope; a db test covers the
backfill, the timeline and the trigger's rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…guarded (#1716)

Review follow-ups: the trigger takes the entity column as a fourth
argument, so kernel.legal_entity rows take their own entity; the kernel
manifest lists the new column; a unit test keeps every ctx.activity
record type either in the trigger's list or org-level with a reason;
the header, timeline comment and tests are tightened. Per-entity rows
that name no entity-scoped record are follow-up #1725.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

⚠️ Code review skipped — your organization has no extra usage available to pay for this review.

If your organization's extra usage balance is empty, an organization admin can add extra usage credits at claude.ai/admin-settings/usage. If its monthly spend limit was reached, an admin can raise it on the same page. If neither applies, contact Anthropic support.

Once extra usage is available, someone with write access to this repository can comment @claude review on this pull request to trigger a review.

@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
crove Ready Ready Preview Oct 28, 2026 10:13am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 20 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8e827e89-8d5c-4079-bbd9-9762008b56e0
Commits

Reviewing files that changed from the base of the PR and between f1dad23 and a184b68.

Files ignored due to path filters: 1
  • packages/db/src/generated/database.ts is excluded by !**/generated/**
Files selected for processing: 7
  • apps/kernel/test/activity-legal-entity.db.test.ts
  • apps/kernel/test/activity-record-types.test.ts
  • apps/kernel/test/invariant/rls-matrix.db.test.ts
  • docs/plan/crove-os-kernel.md
  • packages/kernel/src/manifest.ts
  • packages/kernel/src/timeline.ts
  • supabase/migrations/20261103120000_crove_kernel_activity_legal_entity.sql
  • Autofix · 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.

dos[bot]
dos Bot approved these changes Oct 28, 2026

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

⏱️ Code Review completed (8 eligible files · 49,555 chars · 1 model call(s) · 2 PR unit(s))

Whole diff with bounded context at base 7b47400 / head 3a3f0c7.

✅ CLEAN_PASS: Complete diff reviewed

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request scopes the activity timeline per legal entity for records that belong to one. It adds a nullable legal_entity_id column to the crove_kernel.activity table, introduces a trigger b_legal_entity to automatically populate this column based on the associated record's entity, and applies a restrictive Row-Level Security (RLS) policy (le_scope) to filter activity rows based on the user's legal entity scope. Additionally, it updates the database schema, manifest, documentation, and adds comprehensive tests to verify the new scoping behavior and RLS policies. Feedback on the tests suggests adding a unique tie-breaker (such as id DESC) to the non-unique timestamp sorting (occurred_at DESC) in a test query to ensure deterministic results.

Comment thread apps/kernel/test/activity-legal-entity.db.test.ts Outdated
JOY (JOY) and others added 2 commits October 28, 2026 17:12
dos[bot]
dos Bot approved these changes Oct 28, 2026

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

⏱️ Code Review completed (8 eligible files · 49,578 chars · 3 model call(s) · 2 PR unit(s))

Whole diff with bounded context at base f1dad23 / head a184b68.

✅ CLEAN_PASS: Complete diff reviewed

Context investigation was bounded to the explicitly requested snapshot paths.

@JOY

Copy link
Copy Markdown
Contributor Author

Coordinator's independent security review: APPROVE. Stated plainly for the record: this closes the leak through activity rows, but a member limited to entity A can still read entity B's voucher content (lines, amounts, description) through the proposal itself (kernel.proposal.timeline, inbox.get), because proposals carry no entity scope yet. That is the owner decision in #1538. Low findings go to an Icebox follow-up.

@JOY
JOY (JOY) merged commit f8c488d into main Oct 28, 2026
26 checks passed
@JOY
JOY (JOY) deleted the claude/activity-le-1716 branch October 28, 2026 10:28

This branch was successfully deployed

1 active deployment
Preview — a184b68f Deployed Oct 28, 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.

kernel(tenancy): activity gets legal_entity_id and the RESTRICTIVE le_scope policy

1 participant