Field permissions
Overview
Kompass has two layers of access control:
- Permission groups: control whether a user can use a feature at all (for example view projects, change invoices). See the Permissions reference for the full list.
- Field permissions: a finer-grained layer that restricts individual fields within a record. For example, a user might be allowed to view a project but not see its
price.
Field permissions sit on top of permission groups. If a user does not have the model-level permission they cannot see the record at all. Field permissions only kick in once that base check has passed: they decide which individual fields are visible and editable.
Field permissions are currently available for Projects, Clients, Contacts and Quote Items. The full field lists are under Which fields can be restricted below.
Under the hood this layer is an attribute-based access control (ABAC) system, and you may see the term ABAC in release notes and technical documentation. The design follows the NIST ABAC model (SP 800-162), with rule evaluation based on the OpenID AuthZEN pattern. This article uses the simpler name: field permissions.
The four words you'll keep seeing
Every field permission rule is built from four ideas. You'll see them throughout this article and in the JSON you write, so here they are in plain English first.
Think of a single rule as one sentence:
"Who is allowed to do what to which field, and when."
Each part of that sentence has a name:
| Term | In plain English | The sentence part | In the JSON, this is the key… | Where you find / set its value |
|---|---|---|---|---|
| Resource | Which field the rule is about | …to which field | resource |
The field you want to protect, written as Model.field . For example: Project.price , Client.notes , Contact.email . The full list of protectable fields is under Which fields can be restricted below. |
| Action | What someone is trying to do to that field | …to do what… | action |
Always one of two words: read (see the field) or write (change the field). You write a separate rule for each. |
| Subject groups | Who the rule applies to | Who… | subject_groups |
A list of permission group ID numbers: the same groups you already assign users to. You find each group's ID number in Admin under /admin/auth/group/ (it's in the URL when you open the group). Use the ID number, not the group's name. You can also use "*" to mean "everyone". |
| Condition (optional) | When the rule should apply | …and when | condition |
An optional extra test so a rule only applies to records in a certain state, for example only after a proposal item has been accepted. If you leave it out, the rule always applies. Covered fully under Conditional rules below. |
Two more words complete every rule:
| Term | In plain English | In the JSON, this is the key… | Possible values |
|---|---|---|---|
| Effect | Whether the rule grants access or removes it | effect |
allow (give access to the listed groups) or deny (take it away from them). |
So a full rule reads as: "Allow (effect) groups 5 and 6 (subject_groups) to read (action) Project.price (resource)."
{
"resource": "Project.price",
"action": "read",
"subject_groups": [5, 6],
"effect": "allow"
}
A quick note on where all this lives. These rules aren't set through the normal Kompass screens: they're entered as JSON in the Admin, on the Config for each model (Projects, Clients, Contacts, or Quote Items). The full step-by-step is under Configuring rules below. If you're not comfortable editing JSON in the admin, this is a good one to hand to your Kompass contact.
How rules are evaluated
For each field that has a rule configured, Kompass performs the following steps when a user reads or writes a record:
- Find every rule whose resource matches the field (for example
Project.price). - Discard rules whose condition does not match the record being accessed (see Conditional rules below). Rules without a condition always apply.
- From those rules, pick the ones whose action matches the operation being attempted (read or write).
- From those, pick the ones whose subject_groups include one of the user’s groups (or use the wildcard
"*"). - If any matching rule has
"effect": "deny", the operation is denied. Deny always wins over allow. - Otherwise, if at least one matching rule with
"effect": "allow"is found, the operation is permitted. - Otherwise, the operation is denied.
What “denied” looks like
- Read denied: the field is silently omitted from the API response. The frontend will simply not display it.
- Write denied: the field becomes read-only. Requests that include it succeed (HTTP 200) but the value is ignored; no 403 is returned. This way a save that touches several fields still applies the changes the user is allowed to make, instead of failing outright.
Default behaviour
The default is permissive. If a field has no rules mentioning it at all, every user with the model-level permission can read and write it normally. A field only becomes restricted once you write at least one rule about it.
Once you write any allow rule for a field, that field becomes opt-in: only the groups explicitly listed in an allow rule for the appropriate action can access it. Users not covered by any matching rule lose access. Deny rules behave differently: they only exclude the groups they name and leave everyone else unaffected (see Deny rules below).
Which fields can be restricted
Field permissions are being rolled out gradually. As of July 2026 they cover four models:
- Project: most fields, including
price,price_computed,hours_actual_computed,hours_budget_computed, all of the lifecycle status fields (accepted,quoted,invoiced, …), address fields, references, tags, UDFs, geometries and notes. - Client: name, address, contact, manager, parent, type, tags, UDFs, notes, financial-relevant flags.
- Contact: name, address, email, phone, position, opt-out, tags, UDFs.
- Quote Item: name, price, price type, quantity, the nested cost build-up (
cost_items), lifecycle fields (accepted,closed), service, VAT rate, visibility, address fields, references, tags, UDFs and notes.
Other models (invoices, expenses, diary entries, tasks, leave) do not support field permissions yet. For those, continue using the existing permission groups.
Configuring rules
Field permission rules are stored as JSON on the per-app configuration object. Each rule is one entry in a Field settings list.
- Open the Admin at /admin/.
- Open Projects → Configs, Clients → Configs, Contacts → Configs, or Quote Items → Configs depending on which model you want to gate.
- Pick the org you are configuring.
- In the Field settings JSON editor, paste a list of rules.
- Save. The form validates the JSON; typos in resource or action names are rejected immediately.
Rule shape
{
"rules": [
{
"resource": "Project.price",
"action": "read",
"subject_groups": [12, 34],
"effect": "allow"
}
]
}
Each rule has four required fields, plus one optional field:
| Field | Description |
|---|---|
resource |
The gated field, written as Model.field . For example Project.price , Client.notes , Contact.email . Nested structures are gated as a whole via their field name: for example QuoteItem.cost_items covers the entire cost build-up. Individual sub-fields inside nested data cannot be gated, and dotted paths are rejected. |
action |
Either "read" or "write" . A separate rule is required for each. |
subject_groups |
Either a list of group IDs (integers, not names), or the wildcard string "*" to match every authenticated user. |
effect |
Either "allow" or "deny" . An allow rule grants access to the listed groups; a deny rule takes it away from them. Deny always wins over allow. See Deny rules below. |
condition |
Optional. Restricts the rule to records in a particular state, for example only once the accepted date is set. See Conditional rules below. |
Finding group IDs
Open /admin/auth/group/ in Admin. Each group’s database ID is shown in the URL when you open it. Rules reference these IDs, not the group names, so renaming a group will not break its rules.
Conditional rules
A rule may carry an optional condition that restricts it to records in a particular state. The rule then only takes part in the decision for records that match the condition; for records that do not match, the rule is ignored entirely.
{
"resource": "QuoteItem.cost_items",
"action": "write",
"subject_groups": [6],
"effect": "allow",
"condition": {"field": "accepted", "is_set": true}
}
A condition has two required keys:
| Key | Description |
|---|---|
field |
The field whose state is tested, on the same model as the rule's resource . It must itself be a gateable field; the admin form validates this. |
is_set |
true matches records where the field holds a value; false matches records where it is empty. Empty means null, an empty string, zero or false: a boolean field that is switched off counts as not set. |
Two consequences follow from "ignored entirely":
- A field whose rules are all conditional stays completely open on records that do not match the condition. In the example above, anyone with the model-level permission can edit the cost build-up while the quote item is unaccepted; once the
accepteddate is set, only group 6 can. - When there is no record to test, in particular when creating a new record, conditional rules do not apply and the field stays open. Conditions lock down existing records; they cannot gate creation.
Deny rules
Allow rules work as opt-in lists: once a field has one, only the listed groups keep access. A deny rule works the other way round: it takes the named action away from the named groups and leaves everyone else untouched.
{
"resource": "Project.price",
"action": "write",
"subject_groups": [9],
"effect": "deny"
}
With this single rule, everyone with the model-level permission can still read and write Project.price , except group 9, for whom the field is read-only. Three properties to remember:
- Deny wins over allow. If a user's groups match both an allow and a deny rule for the same field and action, the deny rule applies. This lets you write a wildcard allow with exceptions.
- Deny rules do not make a field opt-in. A field whose only rules are deny rules stays open for everyone the deny does not name. This makes deny the natural choice for "everyone except group X"; compare Examples 4 and 6.
- Denying read also blocks writing. A read-denied field is removed from the response entirely, and values sent for a field that is not present are ignored on save. So a deny rule for
readtakes both actions away from its groups, regardless of the write rules. The reverse does not hold: denyingwriteleaves the field visible but read-only.
Conditions work on deny rules exactly as on allow rules: a deny rule with "condition": {"field": "accepted", "is_set": true} only takes effect once the record is accepted.
Worked examples
Example 1: Hide project price from junior staff
Assume your organisation has these groups:
- Group
4is Junior staff - Group
5is Project managers - Group
6is Finance
You want junior staff to view projects, but not see the price field. Project managers and finance should see and edit it.
{
"rules": [
{
"resource": "Project.price",
"action": "read",
"subject_groups": [5, 6],
"effect": "allow"
},
{
"resource": "Project.price",
"action": "write",
"subject_groups": [5, 6],
"effect": "allow"
}
]
}
Result:
- Junior staff:
priceis omitted from the project API response: the field does not render in the UI at all. Attempting to set it via API silently fails. - Project managers and finance: full read/write access as before.
Example 2: Read-only financials for everyone except finance
Allow everyone to see a project’s computed price and hours, but only finance can change them.
{
"rules": [
{
"resource": "Project.price",
"action": "read",
"subject_groups": "*",
"effect": "allow"
},
{
"resource": "Project.price",
"action": "write",
"subject_groups": [6],
"effect": "allow"
}
]
}
The wildcard "*" on the read rule grants every user read access; the write rule only matches finance (group 6). Other users get the field shown in the UI but as read-only.
Example 3: Hide client notes from external collaborators
External users (group 9) can see clients but should not see internal notes or the manager field. Internal staff (groups 1, 2, 3) keep full access.
{
"rules": [
{
"resource": "Client.notes",
"action": "read",
"subject_groups": [1, 2, 3],
"effect": "allow"
},
{
"resource": "Client.notes",
"action": "write",
"subject_groups": [1, 2, 3],
"effect": "allow"
},
{
"resource": "Client.manager",
"action": "read",
"subject_groups": [1, 2, 3],
"effect": "allow"
}
]
}
The external group is not listed on any rule for these fields, so the fields are omitted from the API response when an external user fetches a client. Internal staff are unaffected.
Example 4: Hide contact phone numbers from a specific group
{
"rules": [
{
"resource": "Contact.phone_mobile",
"action": "read",
"subject_groups": [1, 2, 3, 5, 6],
"effect": "allow"
},
{
"resource": "Contact.phone_office",
"action": "read",
"subject_groups": [1, 2, 3, 5, 6],
"effect": "allow"
}
]
}
Group 9 (for example, junior contractors) is excluded from both lists, so phone numbers disappear from their view of every contact. Other groups see the phone numbers as before.
Example 5: Freeze accepted quote items
Once a quote item has been accepted, its financial fields should no longer change, except by finance (group 6). Unaccepted items should stay fully editable for everyone.
{
"rules": [
{
"resource": "QuoteItem.cost_items",
"action": "read",
"subject_groups": "*",
"effect": "allow",
"condition": {"field": "accepted", "is_set": true}
},
{
"resource": "QuoteItem.cost_items",
"action": "write",
"subject_groups": [6],
"effect": "allow",
"condition": {"field": "accepted", "is_set": true}
}
]
}
Result:
- Unaccepted quote items: no rule matches, so the cost build-up is not gated at all. Everyone with the model-level permission can view and edit it as before.
- Accepted quote items: everyone can still see the cost build-up (the wildcard read rule), but only finance can change it.
Note the conditional read rule. Without it, the moment the write rule's condition matched, the field would count as gated and reads would be denied for everyone (see Forgetting the write rule above); the cost build-up would vanish from the UI on acceptance. When a conditional rule locks writes, pair it with a conditional read rule with the same condition.
Example 6: The same, with deny rules
Example 4 hides phone numbers from group 9 by listing every other group in an allow rule. A deny rule expresses this directly and keeps working when you later add new groups:
{
"rules": [
{
"resource": "Contact.phone_mobile",
"action": "read",
"subject_groups": [9],
"effect": "deny"
},
{
"resource": "Contact.phone_office",
"action": "read",
"subject_groups": [9],
"effect": "deny"
}
]
}
Result: phone numbers disappear for group 9 (and, because denying read also blocks writing, group 9 cannot change them either). All other groups, including any created later, keep full access. With the allow-list approach of Example 4, a new group would be locked out until added to every list.
Common pitfalls
Forgetting the write rule
Read and write are evaluated separately. A rule for action: "read" does not grant write: you need a second rule for action: "write" if you want users to edit the field too.
Conditional write lock without a read rule
The gating check runs per record: once any rule's condition matches a record, the field is gated on that record for both actions. A lone conditional write rule therefore denies read to everyone as soon as the condition matches. Always pair it with a read rule carrying the same condition (see Example 5).
Denying read when you meant read-only
A deny rule for read removes the field completely for its groups, which also stops them writing it. If you only want to stop a group changing a field while still letting them see it, deny write , not read .
Locking yourself out
If you write a single read rule listing only group 5, every user not in group 5 (including admins) loses access to that field. Add admins explicitly to the subject_groups list, or use "*" on the read rule, to avoid this.
Using group names
The subject_groups list must contain integer group IDs, not group names. Names are not validated and will not match any user, so every user is denied.
Mis-spelling a resource
The admin form validates resource names against the gateable-field catalogue. Typos like Project.Price (capital P) or Project.priec are rejected at save time. If you change the field name, the catalogue version is what the validator expects.
Confusing “not in any rule” with “denied”
A field with no rules at all is open to every user with the model-level permission; field permissions do not restrict it. A field with at least one allow rule becomes restricted: users not covered by an allow rule are denied. Deny rules do not restrict a field this way; they only exclude the groups they name. To keep a field open while still gating others, either leave it out of field_settings entirely, or add a wildcard rule.
Auditing access
When troubleshooting why a user can or cannot see a field:
- Check that the user actually has the model-level permission (view project, etc.). Without it, field permissions are irrelevant.
- Look up the user’s groups in /admin/auth/user/.
- Check the field_settings on the relevant Config and find rules for the field in question.
- For each rule, confirm whether the user’s groups intersect with the rule’s
subject_groupsand whether theactionmatches the operation.
Limitations
- Rules are configured per-org. There is no global ruleset.
- Only the Project, Client, Contact, and Quote Item catalogues are wired up. Other models will follow in later releases.
- There is no end-user UI yet; configuration is JSON in the Kompass Admin.
- Field permissions control per-field visibility, not row-level access. To restrict which projects a user can see, continue using filtersets and permission groups.
Field permissions are a young feature and are being expanded gradually. If you need to restrict fields on a model that isn’t yet supported, or you want a deny-by-default workflow, contact Kompass support.