Files
martmull 85e02f6a4c Record sharing: inherited records follow the parent's complete access policy, in queries and events (#25925)
Follow-up to #25439 and #25914 (both merged), now based on `main`. Two
review findings on the inheritance gate: a parent only asked for a
non-null key or a share row, never for the reader's permission on the
parent object nor their row-level restrictions, so a member blocked from
reading a person by their role still read the person's notes and
attachments through it; and the event path (subscriptions, webhooks,
workflows, logic functions) kept treating every INHERITED record as
open, a second interpretation of the policy next to the SQL one. Inert
until a parent object is PRIVATE and `IS_RECORD_SHARING_ENABLED` is on.

## What changes

- One row access policy builder, `buildRowAccessPolicy`, composes for an
alias the role's permission on the object, its row-level predicate and
the record share gate (OPEN, PRIVATE share rows, INHERITED parents,
SYSTEM and APPLICATION denials). Direct queries go through it for their
root and joined aliases, and so do the column parents and child rows of
an INHERITED object: a gated parent is correlated through the parent row
under that policy, `(fk IS NOT NULL AND EXISTS (SELECT 1 FROM parent p
WHERE p.id = fk AND <policy>))`, a denied parent grants nothing. A
parent the reader may not query grants nothing on a child.
- The builder takes a subject, the pieces of an identity the policy
depends on: object permissions, share principals, owning application,
row-level filter. The repository derives it from its auth context; the
object permission predicate moved to a util the query-level validation
shares, so the API keeps reporting a denied root object with the error
it documents.
- `RecordAccessPolicyService` evaluates that policy for a subject
holding no auth context, in SQL on a system-context repository. For an
INHERITED record's events it decides which of the records the event
snapshot points at, and which live child rows point back at it, the
subject may read; the snapshot stands in for the record so a destroyed
one is still decided on. The record's own share rows stay evaluated in
memory as before.
- A deleted record's links are captured with its deletion: a note's
targets are soft-deleted with it and cascade away when it is destroyed,
so the repository reads the child rows an INHERITED record inherits
through before a soft delete or a destroy, and the `deleted` and
`destroyed` events carry them as `inheritedReadabilityChildRecords`, a
server-side transit property stripped before any client payload. The
service evaluates those captured rows as snapshots under the subject's
policy (object permission, row-level filter, the child's own share rows
or parents), so the readers who could see the record through its links
still receive its deletion. Subscriptions and logic functions strip the
capture before handing the event over; webhooks and workflows only ever
took `before` and `after`.
- The query gate applies the same rule to a trashed record: a child link
counts when it is live, or when it was trashed at or after the record
itself. A note soft-deleted with its targets stays visible in the trash,
and restorable, for the readers who saw it live; a target detached
before the deletion still grants nothing.
- A soft delete through the ORM now touches live rows only, unless the
builder opted into `withDeleted`, matching the rows its own snapshot
select already returned. The target cascades restamped targets detached
earlier with the current time, which the rule above would have read as a
link trashed with the note.
- The note and task target hooks, which trash and restore the targets
with their note or task, now run with permission checks bypassed. They
cascade a mutation the actor was already permitted on the parent, and
the repository they built without a role config carried no object
permission at all, which the inherited gate reads as denied on every
parent: with the flag on, they trashed nothing.
- Subjects per consumer: a subscriber's object permissions, principals
and row-level filter; the standard application's default role for
workflows; the application's default role for logic functions; everyone
alone for webhooks, which carry no identity.
`resolveRecordShareGateKind` returns `inherited` for an INHERITED object
and the gate carries the record ids readable through parents next to the
share rows; `isRecordSharedWithPrincipals` became
`isRecordAdmittedByRecordShareGate`.
- The subscription publisher checks that a stream has a query on the
event's object before building its gate, so a batch no longer costs a
parent lookup per stream subscribed to other objects.

## Things to know

- A gated parent now costs one correlated EXISTS on the parent table per
candidate row instead of a share-row lookup keyed by the foreign key,
since the parent's predicate needs the parent row.
- An INHERITED object's events cost, per subject and per batch, one
gated query per parent object and per child object of that batch, and a
soft delete or destroy of such a record costs one read of its child rows
per child object at write time.
- The timeline entries exposing a private linked record's title (#25454)
remain a prerequisite before activation, as does a backfill of share
rows for the notes and tasks that exist unattached before the flag turns
on (they hold no parent and no share row, so the gate would hide them
from everyone).

## Tests

- Unit: `buildRowAccessPolicy` composition (open, denied by object
permission, role predicate alone, share rows, parents under their own
predicate, a parent the subject may not read dropped, owning
application), the condition builder on policy-shaped parents including
the trashed-with-the-record rule, the gate builder on INHERITED, the
gate kind; the mutation builder keeping a soft delete off trashed rows
unless `withDeleted`; the event formatter carrying the captured child
rows on the deleted and destroyed events of their record only; the
publisher skipping the parent lookup for a stream on another object and
stripping the capture from what it broadcasts; the logic function
payload without the capture.
- Integration, in the children spec: a member whose role cannot read
people no longer sees the private person's notes, targets and
attachments through it even with a share row; the event gate resolves,
for the member's subject, exactly the notes the member's query returns;
a note on the private person soft-deleted by the admin (who reaches it
through its company target) has its targets trashed with it, is
unreadable through its live links and readable through the captured
ones, and the admin restores it through the target trashed with it; a
note deleted after one of its targets was detached stays out of the
member's trash. In the note target hooks spec: a target detached before
its note keeps its own deletion time when the note is deleted.
2026-09-15 12:32:51 +00:00

165 lines
12 KiB
Plaintext

---
title: Objects
description: Declare new record types — custom tables with their own fields — using defineObject.
icon: "table"
---
Custom **objects** are new record types your app adds to a workspace — Post Card, Invoice, Subscription, anything specific to your domain. Each object declares its schema (fields, relations, default values) and a stable universal identifier that survives across syncs and deploys.
```ts src/objects/post-card.object.ts
import { defineObject, FieldType } from 'twenty-sdk/define';
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
}
export default defineObject({
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post Card',
labelPlural: 'Post Cards',
description: 'A post card object',
icon: 'IconMail',
fields: [
{
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
name: 'content',
type: FieldType.TEXT,
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
},
{
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
name: 'recipientName',
type: FieldType.FULL_NAME,
label: 'Recipient name',
icon: 'IconUser',
},
{
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
name: 'recipientAddress',
type: FieldType.ADDRESS,
label: 'Recipient address',
icon: 'IconHome',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
name: 'status',
type: FieldType.SELECT,
label: 'Status',
icon: 'IconSend',
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{ value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' },
{ value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' },
{ value: PostCardStatus.DELIVERED, label: 'Delivered', position: 2, color: 'green' },
{ value: PostCardStatus.RETURNED, label: 'Returned', position: 3, color: 'orange' },
],
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
name: 'deliveredAt',
type: FieldType.DATE_TIME,
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
},
],
});
```
## Key points
- The `universalIdentifier` must be unique and stable across deployments.
- Each field requires a `name`, `type`, `label`, and its own stable `universalIdentifier`.
- The `fields` array is optional — you can define objects without custom fields.
- `openRecordIn` sets where records of this object open when clicked: `ObjectOpenRecordIn.USER_CHOICE` (the default, following each workspace member's own preference from Settings → Experience), `ObjectOpenRecordIn.SIDE_PANEL`, or `ObjectOpenRecordIn.RECORD_PAGE`. Pin it to `RECORD_PAGE` for records that need a full page to be usable, the way workflows and dashboards do, or to `SIDE_PANEL` for records that only make sense as a quick panel, the way calendar events do.
- `writability` controls who may write records of the object at all, before role permissions apply: `MetadataWritability.OPEN` (the default — workspace roles decide), `MetadataWritability.APPLICATION` (only your app's own logic functions can create, update, or delete records; use this for configuration-like objects whose records grant behavior, so workspace members with broad record access cannot edit them through the API), or `MetadataWritability.SYSTEM` (reserved for platform-managed data). Reads are unaffected — this is enforced server-side, unlike `isUIEditable`, which only hides UI affordances. It also exists per field, where it can only be stricter than the object's level.
- `readability` declares who may read records of the object: `MetadataReadability.OPEN` (the default — workspace roles decide), `MetadataReadability.PRIVATE` (only principals the record was shared with), `MetadataReadability.INHERITED` (whoever reads the parent record), `MetadataReadability.APPLICATION` (only your app's own logic functions) or `MetadataReadability.SYSTEM` (platform-managed data). `SYSTEM` is always enforced. `PRIVATE`, `INHERITED` and `APPLICATION` are enforced on workspaces where record sharing is enabled (`IS_RECORD_SHARING_ENABLED`); elsewhere they still behave like `OPEN`.
- `readabilityParentFieldUniversalIdentifiers` goes with `MetadataReadability.INHERITED`: the universal identifiers of the relation fields that lead to the parent records.
- A many-to-one field names the record the row points to. Declaring one field of a morph relation covers every target of that relation.
- A one-to-many field names a child object whose rows point back at the record, such as the join rows of a many-to-many relation. The record is then readable when at least one of those rows leads to a readable record: a note follows the people, companies and opportunities its note targets attach it to.
- Several parents combine as a union: one readable parent is enough.
- A parent grants access under its complete policy, the one a query of the parent applies: the reader's permission on the parent object, the row-level restrictions of their role and the parent's own readability. A record never shows through a parent its reader could not query. Subscriptions, webhooks, workflows and logic functions decide what an event of an `INHERITED` record reaches by that same policy.
- The share rows on the record itself grant access exactly as on a `PRIVATE` record. Its creator gets one when the record is created while record sharing is enabled, so whoever created a record keeps seeing it, and a record whose parent fields are all empty is visible to those it was shared with directly.
- An `INHERITED` object that names no usable parent field behaves like a `PRIVATE` one.
- `color` sets the accent color of the object in the UI. Omit it and Twenty picks one for the object.
- `imageIdentifierFieldMetadataUniversalIdentifier` names the field whose value is shown as the record avatar. Omit it to let Twenty pick the default.
- `isLabelSyncedWithName`, on an object or on a field, keeps the API name in sync with the label when the label is edited in Settings. It defaults to `false`, so a renamed label leaves the name untouched.
- `isSearchable: true`, on a field, includes its values in the object's full-text search (global search and the command menu). It requires the object itself to be searchable and a text-compatible field type. An omitted value means not searchable, except for the object's label identifier field, which is always searchable and cannot be opted out. Each sync enforces the declared state, so a field toggled searchable from Settings reverts on your app's next sync unless the manifest declares it.
- `isAuditLogged: false`, on a field, keeps its changes out of the record timeline. It defaults to `true`, except on a `POSITION` field, whose changes render blank in the timeline and are never logged. Set it on fields your app rewrites on a schedule, such as a last-contact timestamp or a rollup relation, so each sync does not add an activity row to every record it touches. An update whose whole diff is made of non audit logged fields produces no timeline activity at all. Like `isSearchable`, each sync enforces the declared state.
- Inline fields defined here do **not** need an `objectUniversalIdentifier` — it's inherited from the parent object. Use [`defineField()`](/developers/extend/apps/data/extending-objects) to add fields to objects you don't own.
- You can scaffold new objects with `yarn twenty dev:add object`, which guides you through naming, fields, and relationships. See [Architecture → Scaffolding entities](/developers/extend/apps/getting-started/scaffolding).
<Note>
**Base fields are added automatically.** When you define a custom object, Twenty creates standard fields like `id`, `name`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, and `deletedAt` for you. You don't need to declare them in your `fields` array — only your custom fields. You can override a default field by declaring one with the same name, but this is rarely a good idea.
</Note>
## Field types
The full set of `FieldType` values, exported from `twenty-sdk/define`:
| Category | Types |
|----------|-------|
| Text | `TEXT`, `RICH_TEXT`, `ARRAY` (of strings), `RAW_JSON` |
| Numeric | `NUMBER` (`universalSettings.dataType`: `'float'` / `'int'` / `'bigint'`), `NUMERIC` (arbitrary precision), `RATING`, `POSITION` |
| Dates | `DATE`, `DATE_TIME` |
| Choice | `BOOLEAN`, `SELECT`, `MULTI_SELECT` |
| Composite | `FULL_NAME`, `ADDRESS`, `EMAILS`, `PHONES`, `LINKS` (`universalSettings.type`: `'url'` (default) / `'domain'` — a domain field stores the bare host, so `https://www.acme.com/careers` is written as `acme.com`, and a value that is not a host is rejected), `CURRENCY`, `ACTOR`, `FILES` |
| Identifiers & relations | `UUID`, `RELATION`, `MORPH_RELATION` (see [Relations](/developers/extend/apps/data/relations)) |
| System | `TS_VECTOR` (full-text search vector, managed by the server) |
Composite types store multiple sub-fields (e.g. `FULL_NAME` = first + last name; `CURRENCY` = `amountMicros` + `currencyCode`). `SELECT` and `MULTI_SELECT` require an `options` array as in the example above.
### Select options
For `SELECT` and `MULTI_SELECT`, provide a non-empty `options` array of objects. Each option has a `value`, `label`, and `position`. Its `color` is optional and defaults to `gray` when omitted or null.
When provided, `color` must be one of: `red`, `ruby`, `crimson`, `tomato`, `orange`, `amber`, `yellow`, `lime`, `grass`, `green`, `jade`, `mint`, `turquoise`, `cyan`, `sky`, `blue`, `iris`, `violet`, `purple`, `plum`, `pink`, `bronze`, `gold`, `brown`, `gray`.
Both `defineObject()` and `defineField()` return validation errors for unsupported colors or option entries that are null, primitives, or arrays.
## Default values
Literal string defaults must be wrapped in single quotes **inside** the string — `defaultValue: "'Draft'"`, not `defaultValue: "Draft"`. That's why the `status` field above uses `` `'${PostCardStatus.DRAFT}'` ``.
Unquoted strings are reserved for computed defaults, evaluated when a record is created:
- `'uuid'` — generates a UUID (for `UUID` fields)
- `'now'` — the current timestamp (for `DATE_TIME` fields)
The same convention applies to string sub-fields of composite defaults (e.g. `{ source: "'MANUAL'" }` on an `ACTOR` field) and to `SELECT`/`MULTI_SELECT` values. A literal string default left unquoted raises a warning when your app is built.
## Nullability
`isNullable` controls whether a field accepts `NULL`. It defaults to `true` — omit it for optional fields. Set `isNullable: false` to make a field required at the database level.
Changes to `isNullable` are applied on every sync, including syncs that update an existing field — so you can flip a field's nullability by editing the manifest and re-syncing.
<Note>
**Making an existing field non-nullable requires a default value.** When you change a field to `isNullable: false`, you must also provide a non-null `defaultValue`. The default backfills any existing `NULL` rows before the `NOT NULL` constraint is applied; without it the sync fails with `Default value cannot be null for non-nullable fields`. Relation fields and `TS_VECTOR` fields are always nullable, so `isNullable` has no effect on them.
</Note>
```ts
{
universalIdentifier: 'b1a7c0de-1234-4f00-9abc-000000000000',
name: 'reference',
type: FieldType.TEXT,
label: 'Reference',
isNullable: false,
defaultValue: "'N/A'",
}
```
## What's next
- **Connect this object to others** — see [Relations](/developers/extend/apps/data/relations) for the bidirectional relation pattern.
- **Add fields to objects from other apps** — see [Extending Objects](/developers/extend/apps/data/extending-objects) for `defineField()`.
- **Display this object in the UI** — see [Navigation Menu Items](/developers/extend/apps/layout/navigation-menu-items) to add a sidebar entry; see [Views](/developers/extend/apps/layout/views) to add custom list configurations.