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
FieldMetamay name apermission; 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_restrictedblanks gated fields in a response anddrop_restricted_inputremoves them from an incoming payload (src/cluster_erp/core/field_access.py:48-72).build_scopereturns 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_scopeadds anINfilter 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.readno 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_scopecan never widen access: an empty allowed set becomesIN ()(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_allis reserved for paths whose own authorisation already established a systemic right, such as retention purge orread_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 withgrep -c "def test_").
Interaction with other parts¶
- Permission codes behind
read_allandread_sensitiveare ordinary codes; see roles and permissions. - Resource-attribute policies are a third, record-level refinement; see ABAC policies.
- The intranet access helpers are documented in intranet access, audience, and retention.
Limits and open points¶
- Only intranet code calls
row_scopeandrlsat this commit (grepforbuild_scope,apply_scope, andpublish_scopeoversrc/cluster_erp). Other personal records have no row scope in code. redact_restrictedblanks 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 |
Related pages¶
- Identity, access, tenancy, privacy, and licensing
- Roles and permissions
- ABAC policies
- Change history
- Metadata model
Verified against commit 92f8051 on 2026-11-13.