Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Column attributes

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:

  1. stripNobodyWritableFields() — fields with Audience::None (every path),
  2. stripExternalReadonlyFields() — #[ExternalReadonly] (external only),
  3. stripExternalAdminOnlyFields() — #[ExternalAdminOnly], skipped for a privileged caller,
  4. stripFieldsOutsideWriteAudience() — the insert/update axes of #[FieldAccess],
  5. applyExternalOwner() and applyExternalRemoteIp() — 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#

  1. 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) is insert: All, update: Privileged — the client must send it on creation but must not move the record.
  2. 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,
  1. Update the allowlist in tests/Unit/Security/OwnerWritableFieldsTest.php (EXPECTED_OWNER_WRITABLE). For every entity with an owner grant on save the test computes what the owner writes after all strips and fails in both directions: on a new writable field and on a stale allowlist.
  2. Verify live with an external save using 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#

  1. Add the key to the manager's $graphDataKeys (pattern: options, image, imageDelete on Parameter).
  2. The sync hook (beforeSave/afterSave) reads it from $meta.
  3. 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: CategoryRelsPaidSanitizationTrait on adverts).
  4. Send an external save with the key in data and 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#

  1. The updateColumns permission — if it passes, the attributes are not read at all.
  2. Only on a denial is the request walked column by column looking for an attribute.
  3. One column without an attribute brings the whole call down. No partial writes.
Permission denied: column "banned" is not updatable without the updateColumns privilege

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."

#[ConditionallyUpdatable(check: 'checkPermissionForRead')]
protected bool $read = false;

And on the Api presenter:

protected function checkPermissionForRead(int $id, mixed $value, ?int $userId): bool
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#

  1. 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.
  2. Decide from the table below which attribute it is.
  3. Write it on the property in the shared base entity — so that it holds for every section.
  4. For the conditional variant write the method on the Api presenter and decide inside it with a database query.
  5. Verify both ways — that the permitted write passes and that a denied one really fails.
  6. 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

  1. Add the column with #[ExternalReadonly] — a counter must not come in a save.
  2. 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, so parent:: points to BasePresenter, which does not have it.
  3. Verify that an allowed field adds 1 and a disallowed one (price) returns INVALID_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

Cookie settings