Factorizer/Cluster ERP WikiCluster ERP Türkçe
Home / Identity and access

Explanation

Field and row access

How core/field_access.py hides gated field values, and how core/row_scope.py and core/rls.py limit personal records to their owner and team.

A permission code decides whether a user may call a route. Two finer layers decide what the user sees in the answer. Field access, in core/field_access.py, removes the values of metadata fields that need a permission code the caller lacks. Row access, in core/row_scope.py with the database backstop in core/rls.py, limits personal records to their owner and the owner's team. The value redaction of change history in core/sensitive_fields.py is a third, separate mechanism; it is documented in change history.

In short

  • A FieldMeta may name a permission; the field's value then reaches only callers who hold that code (src/cluster_erp/core/field_access.py:1-13, src/cluster_erp/core/metadata.py:115-119).
  • redact_restricted blanks gated fields in a response and drop_restricted_input removes them from an incoming payload (src/cluster_erp/core/field_access.py:48-72).
  • build_scope returns one of three scopes: all rows for a holder of <entity>.read_all, the caller's own party plus the transitive team otherwise, and no rows when the caller has no party (src/cluster_erp/core/row_scope.py:62-83).
  • apply_scope adds an IN filter on the owner column; an empty scope yields an empty result, never all rows (src/cluster_erp/core/row_scope.py:92-109).
  • Two tables carry a database row policy that reads a transaction-local setting published from the same scope (migrations/versions/rls01_crown_jewel_rls_backstop.py:32-48, src/cluster_erp/core/rls.py:1-23).

How it works

Field access

restricted_fields walks the form field groups of an EntityMeta, including nested item_fields, and returns each field key with its permission code (src/cluster_erp/core/field_access.py:24-41). denied_fields keeps the keys whose code the caller does not hold (src/cluster_erp/core/field_access.py:44-45). The caller's predicate comes from allowed_from_codes over the caller's permission set, which other modules obtain through actor_allowed or permission_codes_for in modules/auth/guards.py (src/cluster_erp/core/field_access.py:75-77, src/cluster_erp/modules/auth/guards.py:170-186).

Users at this commit, found with grep for redact_restricted and drop_restricted_input:

Caller Use Source
Employee routes Drop gated input on create and update, blank gated output src/cluster_erp/modules/owned_organisations/employees/routes.py:57-112
Party composite route Drop gated employee input src/cluster_erp/server/routes/party_composite.py:118-119
Party roles route Drop gated employee input src/cluster_erp/server/routes/party_roles.py:118

The gated fields declared in code use the code employee.read_sensitive: six declarations in modules/owned_organisations/organisations/metadata.py (for example tax_no, national_id_no, social_security_no, and blood_group at lines 462-500) and six in modules/parties/parties/metadata.py (counted with grep). Tenant-defined fields have their own permission gate through redact_extra; see tenant fields.

Row scope

row_scope is a pure module. It depends only on uuid and SQLAlchemy Core; the caller supplies the data (src/cluster_erp/core/row_scope.py:15-21).

Caller state Resulting Scope Source
Holds <entity>.read_all all_access=True, no filter src/cluster_erp/core/row_scope.py:78-79
No party can be resolved Empty party set, sees nothing src/cluster_erp/core/row_scope.py:80-81
Party resolved Own party plus the transitive team src/cluster_erp/core/row_scope.py:82-83

Scope.may_see decides a single record; Scope.restrict returns the list for a query (src/cluster_erp/core/row_scope.py:46-59). deny_all is the explicit empty scope (src/cluster_erp/core/row_scope.py:86-89).

The intranet module is the user at this commit. resolve_scope in modules/intranet/access.py checks the read_all code, resolves the caller's party and team, builds the scope, and publishes it to the database (src/cluster_erp/modules/intranet/access.py:189-219). The read_all codes are in the seed list: intranet_leave_request, intranet_leave_balance, intranet_request, intranet_official_delivery, intranet_notification, intranet_content, intranet_kudos, and intranet_event (src/cluster_erp/server/app.py:808-825).

Database backstop

The sequence shows how a resolved scope reaches the database.

sequenceDiagram
 participant S as resolve_scope
 participant B as build_scope
 participant P as publish_scope
 participant DB as PostgreSQL row policy
 S->>B: has_read_all, self_party, team
 B-->>S: Scope
 S->>P: Scope
 P->>DB: set_config transaction-local, RLS_PARTIES_GUC
 DB-->>DB: rows filtered by party_id

In prose: resolve_scope computes the inputs and calls build_scope (src/cluster_erp/modules/intranet/access.py:207-218). publish_scope writes the allowed parties, or the unrestricted marker, into a transaction-local PostgreSQL setting named by RLS_PARTIES_GUC (src/cluster_erp/core/rls.py:37-60, src/cluster_erp/modules/intranet/access.py:219). Migration rls01 enables and forces row-level security on intranet_official_deliveries and intranet_notifications; the policy compares each row's party_id with that setting (migrations/versions/rls01_crown_jewel_rls_backstop.py:32-48). An unset or empty setting yields no rows (src/cluster_erp/core/rls.py:3-9). rls_span widens or narrows the setting for one guarded operation and restores the previous value after it (src/cluster_erp/core/rls.py:79-105).

Why it is built this way

  • The field-access docstring states the goal: one metadata declaration makes the web client hide the field and the API strip it, so employee.read no longer reveals an employee's sensitive identifiers (src/cluster_erp/core/field_access.py:3-13).
  • The row-scope docstring states that company isolation is physical (one database per tenant) and that row scope is authorisation inside one company, fail-closed by design (src/cluster_erp/core/row_scope.py:1-13).
  • The RLS docstring states that the application scope stays the primary check and the database policy is a backstop for a query that forgets apply_scope (src/cluster_erp/core/rls.py:11-17).

Rules and boundaries

  • apply_scope can never widen access: an empty allowed set becomes IN () (src/cluster_erp/core/row_scope.py:97-109).
  • The RLS setting is transaction-local, so it does not leak across pooled connections (src/cluster_erp/core/rls.py:19-23).
  • publish_all is reserved for paths whose own authorisation already established a systemic right, such as retention purge or read_all-gated surfaces (src/cluster_erp/core/rls.py:72-76).
  • tests/test_row_scope.py (10 tests) pins the scope policy; tests/test_authz_conformance.py (6 tests) pins the layered model (counted with grep -c "def test_").

Interaction with other parts

Limits and open points

  • Only intranet code calls row_scope and rls at this commit (grep for build_scope, apply_scope, and publish_scope over src/cluster_erp). Other personal records have no row scope in code.
  • redact_restricted blanks attributes on a Pydantic model and removes keys from a dictionary; whether every list endpoint of an entity with gated fields applies it is not determined from code beyond the three callers listed above.

Terms introduced

Term Code identifier Definition Turkish term Source
gated field FieldMeta.permission A metadata field whose value reaches only holders of a named permission code. izinli alan src/cluster_erp/core/metadata.py:115
row scope Scope The set of parties whose records a caller may see. satır kapsamı src/cluster_erp/core/row_scope.py:35
read-all code read_all A permission action that lifts the row scope for an entity. tümünü okuma kodu src/cluster_erp/server/app.py:811
row-level security backstop RLS_PARTIES_GUC A database row policy that filters two personal tables by the published party set. satır düzeyi güvenlik yedeği src/cluster_erp/core/rls.py:37

Verified against commit 92f8051 on 2026-11-13.

Verified against commit 92f8051 · 2026-11-13