Column write attributes#
Who may overwrite a specific column from outside. The generic updateColumns is
deliberately for administrators only — yet the front end legitimately changes a few
counters and flags through it. The exception is therefore carried by the column
itself, as a PHP attribute on the entity's property.
The attributes live in app/Core/Attributes/Hydration/ and are evaluated by
app/Core/Traits/Api/Presenters/SaveTrait.php.
Two generations of attributes side by side#
| Attribute | Occurrences (approx.) | Role |
|---|---|---|
#[ExternalReadonly] |
~630 | the column must not arrive from outside (the server determines the value) |
#[ExternalAdminOnly] |
13 | only a privileged caller may write it |
#[PubliclyUpdatable] |
5 | anyone may write it through updateColumns |
#[ConditionallyUpdatable] |
6 | a named method on the Api presenter decides |
#[FieldAccess] |
~80 | the new, unifying one — three axes: read / insert / update |
The old four are not being removed, they are just not being added to
#[FieldAccess] is the target shape and both are read; a sweeping migration of the
old occurrences will come with a parity test. Write FieldAccess in new code, do not
rewrite finished places for cosmetics. Field audiences in detail:
RBAC in the API.
When converting an old attribute to the new one, these equivalents apply:
| Old attribute | #[FieldAccess] |
|---|---|
#[ExternalReadonly] |
insert: None, update: None |
#[ExternalAdminOnly] |
insert: Privileged, update: Privileged |
#[PubliclyUpdatable] |
update: All |
#[ConditionallyUpdatable(check: 'x')] |
update: Owner, updateCheck: 'x' |
The equivalent holds on the external path only, and updateCheck is not read yet
The old attributes run on the external save only; the new one is role-based and its
read axis applies on both paths. The last row is the target shape: while writes treat
Owner strictly as Privileged, the updateCheck parameter evaluates nothing.
Keep writing a conditional single-column write with #[ConditionallyUpdatable].
Where each axis is enforced#
| Axis | Enforced | Where in the code |
|---|---|---|
read |
when the response is sent, on both paths (external HTTP and ApiBridge), by the audience the guard set |
hydrator emit + FieldAudienceContext |
insert / update with Audience::None |
in save on every path, the administration included |
OrmEntityHydrator::stripNobodyWritableFields() |
insert / update with another audience |
only in an external save (HTTP), by the caller's audience |
SaveTrait::stripFieldsOutsideWriteAudience() |
update on updateColumn(s) |
not evaluated — external updateColumns is closed by an allowlist |
— |
The internal path does not enforce the write axes (except None) yet
Front forms and the administration go through ApiBridge and the audience strip does
not apply to them; their own form rules hold them (the front profile has the e-mail and
username read-only). Extending it to the internal path is planned only after
measurement, because a fail-closed strip on insert depends on whether the manager
really fills in the server-computed fields. The old attributes (ExternalReadonly,
ExternalAdminOnly) also run only under $meta['__external'], which only
actionSave() sets.
Write audience on an external save#
An external save with a conditional owner grant lets the owner reach their record —
and without a further strip they could write anything that has a setter: credits, discount,
membership, e-mail. The strip by the insert/update axes closes that. It runs in
SaveTrait::saveEntityData() in this order:
stripNobodyWritableFields()— fields withAudience::None(every path),stripExternalReadonlyFields()—#[ExternalReadonly](external only),stripExternalAdminOnlyFields()—#[ExternalAdminOnly], skipped for a privileged caller,stripFieldsOutsideWriteAudience()— theinsert/updateaxes of#[FieldAccess],applyExternalOwner()andapplyExternalRemoteIp()— the owner and IP from the server.
The strip means "keep what is in the DB": on update the stored value stays, on insert the
entity default. The response is successful and the strip is written to the auth channel as
EXTERNAL_FIELD_AUDIENCE_STRIPPED presenter=… op=insert|update fields=….
| Audience on a write axis | Who may send the field |
|---|---|
All |
anyone who passed the action guard |
Authenticated |
a caller with an identity |
Owner, Privileged |
only a privileged caller (privileged = 1) — on writes Owner is for now treated strictly as Privileged |
None |
nobody, not even an administrator |
Internal |
not used on write axes (FieldAccessCoverageTest checks it) |
Without a resolved audience the strip is fail-closed
When FieldAudienceContext is not resolved, only Audience::All passes (for the
privileged tier from actionSave() everything except None). An unknown state closes a
field, it does not open it.
Privileged is a database row, not a role name
Locked fields open for every role with privileged = 1 — but only for one that also has
the right to the action. Who may reach User:save is governed by the permission matrix,
not by the attribute.
Procedure: lock a field against the owner#
- Decide the axes. May the field be sent on creation (
insert)? May the owner change it (update)? Typically a link to the parent (threadId,advertId) isinsert: All, update: Privileged— the client must send it on creation but must not move the record. - Write the attribute on the property in the shared base entity. A leaf entity that redeclares the property must carry it itself — the first declaration from the bottom of the hierarchy wins.
#[FieldAccess(read: Audience::All, insert: Audience::Privileged, update: Audience::Privileged)]
protected int $credits = 0,
- Update the allowlist in
tests/Unit/Security/OwnerWritableFieldsTest.php(EXPECTED_OWNER_WRITABLE). For every entity with an owner grant onsavethe test computes what the owner writes after all strips and fails in both directions: on a new writable field and on a stale allowlist. - Verify live with an external
saveusing the owner's token and then the DB row: the locked field does not change, the response is successful.
A new column with a setter is writable for the owner
A field without an attribute behaves as all/all/all. The guard test flags it, but
whether it should be locked is your decision.
Keys outside columns on an external save#
Attributes guard the entity columns. What goes outside the object graph (meta, nested
collections synced by a hook, owner and IP) is controlled by the Api manager through optional
protected properties and two methods. Except for $defaultSkipFields (both paths) they
apply to external HTTP only; the internal path (ApiBridge, admin and front end) sends meta
itself:
| On the manager | Default | What it does on the external path |
|---|---|---|
$externalMetaAllowlist |
[] |
only the listed keys pass from meta, the rest is dropped (log EXTERNAL_META_STRIPPED) |
$externalUpdateColumnsAllowlist |
[] |
updateColumns may change only the listed columns; empty = closed (log EXTERNAL_UPDATE_COLUMNS_STRIPPED) |
$graphDataKeys |
[] |
keys the client may send nested in data; before hydration they move to meta, where the manager's sync hook reads them. If the client also sends the key in meta, meta wins |
$defaultSkipFields |
[] |
on both paths: keys the generic save() does not copy onto the entity (a hook handles them through meta); stripped from data before hydration, otherwise the graph would write them a second time |
getExternalOwnerData(int $userId) |
[] |
on insert fills the owner from the JWT |
getExternalRemoteIpData(string $ip) |
[] |
on insert fills the IP from IRequest::getRemoteAddress() |
No manager overrides either allowlist today, so external meta is dropped entirely and
external updateColumns is closed.
The key returned from getExternalOwnerData() must match the column mapping, not what
looks nicer:
| Who owns the column | Return | Example |
|---|---|---|
| a scalar | ['userId' => $userId] |
Watchdog, Comment, Rating, Message, Comcat Response |
| a mapped column with another name | ['insertUserId' => $userId] |
Advert (adverts, catalogue) |
an association (the scalar has insertable: false) |
['user' => ['id' => $userId]] |
Bazaar Response, AdvertFollower, Complaint |
Procedure: an external client should send a nested collection#
- Add the key to the manager's
$graphDataKeys(pattern:options,image,imageDeleteonParameter). - The sync hook (
beforeSave/afterSave) reads it from$meta. - If the key carries a field the server otherwise dictates (paid, approved), overwrite it on
the external path in
beforeSave()when$meta['__external'] === true(pattern:CategoryRelsPaidSanitizationTraiton adverts). - Send an external
savewith the key indataand check in the DB what was written.
Promoting a key does not make its content safe
$graphDataKeys only says which way the key flows. Without a server-side overwrite of a
sensitive field the client sets, for example, a paid advert without paying.
Wrong owner key = the owner is silently not written
With an association, a scalar with insertable: false never reaches the INSERT.
Unifying Bazaar and Comcat Response "for consistency" breaks one of the modules.
The owner is filled on insert only
On update it would mean that whoever saves a foreign record becomes its owner.
The decision order for updateColumns#
- The
updateColumnspermission — if it passes, the attributes are not read at all. - Only on a denial is the request walked column by column looking for an attribute.
- One column without an attribute brings the whole call down. No partial writes.
An attribute-granted write is logged
An API_COLATTR line appears in the auth channel. Without it the log would show a
denial followed by a successful write — and the log would be lying.
#[PubliclyUpdatable]#
"Anyone may write this column."
#[ORM\Column(name: 'reported', type: 'boolean')]
#[ExternalReadonly]
#[PubliclyUpdatable]
protected bool $reported = false;
The typical case is a "reported" flag on an advert or a post.
It permits ANY value, not just a sensible one
That is why it is unsuitable for counters — votesUp could be set to a million in one
request. Counters are incremented by the registerEvent action, which does a
server-side + 1.
It does not check record ownership
It does not look at who the record belongs to. When that matters, it is a case for the conditional variant.
#[ConditionallyUpdatable]#
"The rule for this column is over there."
And on the Api presenter:
| Parameter | Meaning |
|---|---|
$id |
the id of one record; for a bulk updateColumns the method is called for each id separately |
$value |
the value to be written (you may judge it, usually there is no need) |
$userId |
the identity from getJwtPayload(), never from the request; null = anonymous |
The method may be protected — SaveTrait calls it on the same presenter.
Today it is used by the "read" flag on a reply to an advert and on a reply in the company catalogue — that is, the case where the owner is not on this record but on its parent.
Decide against the database, not against the request
$id only says which record we are talking about. Anything else from the request is
the client's claim — a check over a value from the same request verifies nothing.
The method name is WRITTEN, not derived
A convention like read → checkReadPermission() is a trap: a typo would surface not
as an error but as a silent denial saying "you have no rights". The existence of the
methods is guarded by tests/Unit/Security/ConditionallyUpdatableContractTest.php.
Fail-closed: anything unclear is a denial
A single false on any id brings the whole call down. A missing record and an exception
in the check both count as a denial.
Procedure: I need to allow writing one column from outside#
- First ask whether it really is a column. Incrementing a counter is
registerEvent; a write with business logic is a dedicated Api action with its own permission key. - Decide from the table below which attribute it is.
- Write it on the property in the shared base entity — so that it holds for every section.
- For the conditional variant write the method on the Api presenter and decide inside it with a database query.
- Verify both ways — that the permitted write passes and that a denied one really fails.
- Check the log: an attribute-granted write must be visible as
API_COLATTR.
| Situation | Solution |
|---|---|
| anyone may write it and the value has no degrees | #[PubliclyUpdatable] |
| only someone specific may write it | #[ConditionallyUpdatable] |
| a counter that should go up by 1 | neither — the registerEvent action |
| a write with its own business logic | neither — a dedicated Api action |
| the value is determined solely by the server | #[ExternalReadonly] |
An attribute on a property means: anyone signed in, as many times as they like
It sits on the entity and is read by the boundary both paths go through. Marking a column is not "allowing one form" — it is a blanket permission.
A property must not carry both attributes at once
It would be unclear which rule applies. The same test guards it.
An association on the same column needs the attribute too
The strip goes by property name, not by column. Advert maps insert_user_id twice:
as the scalar insertUserId in the shared entity and as the user association in the
Api entity. If only the scalar carried #[ExternalReadonly], a client would send
data.user = {"id": X} and move the advert to another account. When you add an
association to a column that already has a scalar with the attribute, give it the same
attribute.
Counters: registerEvent#
The registerEvent action (RegisterEventTrait) gets ids and a field name and runs a
server-side SET <field> = <field> + 1. The field name comes from the caller, and the mapper
under the action accepts any field of the entity — the $externalUpdateColumnsAllowlist does
not reach this path. That is why internalActionRegisterEvent() lets through only fields from
registerEventProperties(); anything else returns INVALID_VALUE and writes nothing.
| Presenter | Allowed counters |
|---|---|
| default (trait) | views, viewsDetail, viewsList |
Discussion MessagePresenter |
additionally votesUp, votesDown |
Procedure: a new counter
- Add the column with
#[ExternalReadonly]— a counter must not come in asave. - In the Api presenter override
registerEventProperties()and return the whole list, including the three defaults.parent::registerEventProperties()does not work: the method comes from a trait, soparent::points toBasePresenter, which does not have it. - Verify that an allowed field adds 1 and a disallowed one (
price) returnsINVALID_VALUE.
Without the allowlist any column of someone else's record could be incremented
Some registerEvent keys are granted to guests and registered users too, because the web
increments the counters. A field name from the request would otherwise allow changing
price, paid, deleted or topedUntil of someone else's advert.
The allowlist sits behind the guard and applies on both paths
The check is in internalActionRegisterEvent(), which both external HTTP and the bridge pass
through, not in actionRegisterEvent(). A denied caller learns nothing about the list.
Coverage is guarded by structural tests#
| Test | What it checks |
|---|---|
ExternalReadonlyCoverageTest |
that server-computed fields carry #[ExternalReadonly] |
ExternalAdminOnlyCoverageTest |
the same for admin-only columns |
ConditionallyUpdatableContractTest |
that the named check method exists |
FieldAccessCoverageTest |
that a field whose name looks like a secret is annotated or has an exception with a reason; that Audience::Internal is only on the read axis |
OwnerWritableFieldsTest |
what an owner may write through an external save on every entity with an owner grant — against an allowlist, in both directions |
Coverage by field name is a sieve, not a protection
The test recognises password or secret. A field with an innocent name that still
carries a secret (meta, config, note) has to be annotated by a human.
Where to look#
| I want | Where |
|---|---|
| the attribute definitions | app/Core/Attributes/Hydration/ |
| the evaluation on write | app/Core/Traits/Api/Presenters/SaveTrait.php |
| stripping fields by permission | the same file → stripExternalReadonlyFields(), stripExternalAdminOnlyFields(), stripFieldsOutsideWriteAudience() |
| the caller's audience | app/Core/Security/Rbac/FieldAudienceContext.php |
| the counter allowlist | app/Core/Traits/Api/Presenters/RegisterEventTrait.php → registerEventProperties() |
| the coverage tests | tests/Unit/Security/ |
Follow-up chapters: RBAC in the API · Hydrators · Manager lifecycle