Skip to content

fix(permissions): one record rule for every read, update and delete, across projects and through the records a record names - #4526

Merged
simlarsen merged 19 commits into
masterfrom
claude/label-blocks-everywhere
Oct 7, 2026
Merged

simlarsen merged 19 commits into
masterfrom
claude/label-blocks-everywhere

Conversation

@simlarsen

Copy link
Copy Markdown
Contributor

Problem

Reads, updates and deletes did not all narrow their records by the same rule. A team's block with labels left records out of a read in one project, but not out of a list read across all of a user's projects, an update or delete by query, or a record that carries no labels of its own. A grant limited to labels narrowed labelled records, but not the records without labels that belong to them, and a record read through another one (an incident's note) could be changed or deleted through a parent the caller may not read. A filter naming a record through a relation could replace the request's own filter on the relation's key, and a check of one record by id read the record before narrowing it to the caller's project.

What changed

One record rule now narrows every read, update and delete of the CRUD path, in the project the request acts in (BasePermission.addRecordScopeToQuery):

  • A request keeps to its project. A filter on project, projectId, or any relation given as a plain id, { _id } or Includes is written onto the relation's key and combined with the condition already there, never in place of it (QueryUtil.moveRelationFilterToKey). A record of another project, named by its id or in a filter, is answered as if it did not exist.
  • Lists across projects narrow each project by its own rows. A read across all of a user's projects (the home page's lists) narrows each project's records by that project's grants and blocks, exactly as a request in that one project is narrowed (TenantPermission); a block in the project the request names leaves out that project's records only. Projects are worked out four at a time (TenantPermission.PROJECT_CONCURRENCY), however many a user belongs to.
  • Updates and deletes follow the read rule. Label grants, Owned grants, the record a model is read through, and the team's blocks narrow updates and deletes, by id and by query. A block with no labels on the operation's permission refuses an update or a delete by query as it refuses a read; a block with labels leaves the records carrying them out.
  • A record read through another one keeps to parents the caller may read. An incident's note is read, changed and deleted only through an incident the caller's read grants reach. A change also keeps to the incidents the caller may edit, as it always has; a delete is the note's own permission and weighs no delete grant on incidents. A write is narrowed by conditions on the record's own id, one per list of labels (ReadPermission.addParentLabelsToQuery, QueryHelper.oneLinkedToAny), so the caller's own filters stay exactly as sent and a record readable through one parent and editable through another (an announcement on two status pages) is reached. A read narrows the relation itself, as before.
  • Records without labels carry the labels of what they name, for grants as for blocks. ReadPermission.addLabelRulesToQuery is the one helper for both halves, over the same references: the model's parent, its key columns, and resource ids of any kind. A grant limited to labels reaches such a record when one of the records it belongs to or names carries one of the grant's labels (one condition on the record's id, QueryHelper.namesNothingOrOneLinkedToAny); a block with labels leaves it out when one of them carries a blocked label.
  • A check of one record reads it in the caller's project, once. DatabaseService.findWithAccessControlIds (used by updateOneById, deleteOneById and findOneUpdatableById) names the record in the caller's project, with its project, before reading its labels. AccessControlPermission.checkRecordByModel runs the block and the grant check of an update or delete by id on one read of the record, looks nothing up when the label rule adds nothing to it, and answers a record of another project, or one read without its project, as missing. A refusal names the block rows it weighed. The services that read the record themselves now select its project.
  • findOneUpdatableById answers nothing for a record outside the update scope. A record with no labels of its own is weighed by the update scope's query, so a custom route that changes a record as root (a custom domain's checks) answers a record outside the caller's labels like a missing one.
  • Analytics rows follow the record they belong to. Monitor logs (monitor), SLO history (SLO), network flows (network device) and Kubernetes cost allocations (cluster) are read through their parent: Owned and Labels reach the rows of the parents the caller may read, and a block with labels leaves out the rows of the parents carrying them (@OwnedThrough(..., { onlyParentModels: true })). An AI insight follows the service it names (@OwnedThrough("telemetryServiceId", Service, { includeUnattributed: true })).
  • Live updates follow the same rule. A write to a key the label rule follows counts as a write that may change who reads the row.
  • hardDeleteBy deletes the rows its narrowed lookup found by id, with the query's filters on the table's own columns; filters on relations stay with the lookup, since a DELETE statement cannot join them.

Decisions

  • Label grants reach records without labels through the same references as blocks, and through any one of them: a record carries the labels of every record it names, as a labelled record is reached through any one of its own labels.
  • A record that names no labelled record is about none of them: a grant limited to labels keeps it and a block with labels does not take it away. A block with no labels is what takes a whole table away. A key that can name records of several kinds counts as naming a labelled record whenever it is set.
  • A record read through another one is reached only through a parent the caller may read, on every operation. Updates keep weighing the parent's edit grants as well, as before (custom domains rely on it); deletes weigh none of the parent's delete grants, as before. When the parent's model carries no labels, grants limited to labels reach none of its records.
  • An AI insight about no service belongs to the project.
  • The audit log and threat intelligence indicators name no owning record, so they stay readable across the project by whoever may read them. The docs say so.
  • Lists across projects are worked out four projects at a time, to bound the work on the shared database pool.

What changes for customers

  • Teams with grants limited to labels see, change and delete only the records without labels (notes, announcements, remediation decisions, inventory items, endpoints, ...) of which one named incident, alert, monitor, status page, service or resource carries one of the grant's labels, plus the records that name none.
  • Teams with block rows have them applied in lists across all projects, as in one project, and to updates and deletes as to reads.
  • Changing or deleting a note (or another record read through an incident, alert, status page, ...) needs an incident the team may read, as reading it does. Changing it still needs an incident the team may edit when its edit grants are limited to labels.
  • Monitor logs, SLO history, network flows, Kubernetes cost allocations and AI insights follow the Owned and Labels scope of the record they belong to.
  • A filter naming another project's record finds nothing, and changing or deleting it by id answers "not found".
  • Teams with project-wide grants and no blocks see no change.

The permissions page says all of this, in every docs language.

Left out

  • A request across projects that also filters on projectId with a plain id reads each of the user's projects in turn; the filter does not pick one of them (an Includes filter does). Unchanged here.

Tests

  • Common/Tests/Server/API/RecordScopeCrudPostgres.test.ts (new, 119 tests, runs in the Postgres workflow): the real BaseAPI routes over migrated Postgres, for a user and an API key: list, count, read, update and delete by id and by query with project, projectId and relation ids (plain, {_id}, Includes); the permission matrix (grant, block with no labels, block with labels, both) on every path; reads across projects, with and without the project in view named; records named through their parents and through several keys; notes changed and deleted through alerts the caller may read and edit; an announcement changed through one status page the caller may read and another they may edit; AI insights; and a statement count that does not grow with the rows read.
  • Common/Tests/Server/Types/Database/Permissions/RecordScopeEveryOperation.test.ts (new): the record rule on read, update and delete, per project across projects, with bounded concurrency; one read of a record for both of its checks; refusals naming the weighed block rows; a note's update and delete narrowed through the incident it belongs to, beside the caller's own filters.
  • Common/Tests/Server/Types/Database/RelationFilterKeepsKeyGuard.test.ts (new): every many-to-one relation keeps its key filter when filtered by id.
  • Common/Tests/Server/Types/Database/QueryHelperLabelGrantOperators.test.ts (new): the grant condition over keys and parents, bound values, quoting, and fail-closed empty lists.
  • Common/Tests/Server/Types/AnalyticsDatabase/ReadThroughParent.test.ts (new): analytics rows read through their parent.
  • Common/Tests/Server/Services/DatabaseServiceRecordInCallerProject.test.ts (new): a check by id reads its record in the caller's project.
  • Common/Tests/Server/Services/DatabaseServiceHardDeleteByRecordRule.test.ts (new): a hard delete under the record rule deletes the rows it found, with no filter on a relation.
  • Extended: QueryUtil.test.ts, OwnedScopePermission.test.ts, OwnedThrough.test.ts, DatabaseServiceFindOneUpdatableById.test.ts (records without labels of their own), DatabaseServiceRealtimeReadAccess.test.ts, OnCallCalendarFeedPermissions.test.ts, TableLevelBlockPermission.test.ts, ProxmoxResourceAPIRemoveNode.test.ts, ResourceAiAccessAPI.test.ts, ResourceAiAccessChangeAuthorization.test.ts, DatabaseServerEndpointService.test.ts, AlertStateChangeAuthorization.test.ts, IncidentDeclareAcknowledgeAlerts.test.ts, CustomDomainChangePermission.test.ts.
  • App/Tests/FeatureSet/Docs/RecordRuleEveryOperationDocs.test.ts (new): the permissions page in all 17 languages.

The new tests were checked against the code they cover: weighing the parent's delete grants on a delete, ignoring the parent's read grants on a write, putting the record lookups back into findOneUpdatableById, or keeping relation filters in the hard delete's statement makes them fail.

What ran

  • packages/Common: npm run compile and npm run compile-tests pass on the branch with the latest master merged in.
  • packages/App: npm run compile passes. The App suites that touch these checks (hard delete job, telemetry entity pruning, workflow run authorization, permissions table, declare-from-alert wiring, phone number API, the two permissions page docs tests) pass: 9 suites, 296 tests.
  • Common Tests/Server, Tests/Types and Tests/Models (2,666 suites, 107,072 tests), with billing off and with billing on: everything passes except suites that fail the same way on master in this environment - the WebAuthn and passkey suites (UserWebAuthnPasskeyService, UserWebAuthnPasskeyIntegration, UserWebAuthnUserVerification, MobilePasskeyLoginIntegration), SsoInCommunityEdition and CreateAndReadResponsesKeepIds (billing off), and RealtimeAccessChangesValkey, which needs its own Valkey (the shared one here refuses its credentials).
  • RecordScopeCrudPostgres against a Postgres migrated with every migration, master's latest included: 119 tests pass.
  • eslint with --fix on every changed file.
  • The mutation checks listed under Tests.
  • Not run: the Playwright suites and the ee packages' tests; nothing in them changed.

🤖 Generated with Claude Code

valeriamustafaeva and others added 19 commits October 7, 2026 11:46
A team's block with labels, and grants limited to labels, now narrow the
rows of every read, update and delete the CRUD path makes, and in a request
across projects each project's own rows are narrowed by that project's
grants and blocks. Records with no labels of their own follow the labelled
records they name, for grants as for blocks. Deletes are narrowed the way
updates are (label grants, Owned scope, the record a model is read
through). The checks on one record read it only in the caller's project, so
a record of another project is answered as a missing one. Monitor logs, SLO
history, network flows and Kubernetes costs are read through the monitor,
SLO, device and cluster they belong to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owned grants reach the insights about the services the caller owns and
the insights about no service; grants and blocks limited to labels follow
the service's labels through the shared label rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stgres

Every request goes through the route BaseAPI registers, the real user
middleware reading team and API key permission rows from the tables, and
the real permission pipeline: lists, counts, reads, updates and deletes
keep to the request's project whatever project or relation the request
names, the label rule holds on every path for teams and keys, reads across
projects apply each project's own rows, and an AI insight follows the
service it names.

A relation filtered with Includes is now a filter on the relation's key,
joined to what the key holds, like a relation filtered by one id. A block
with no labels on an update or a delete by query refuses it, as it refuses
a read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s of what they name, on Postgres

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ilters keep their key

The record rule asked by a read, an update and a delete: a block with no
labels, a block with labels and grants limited to labels, each with the
permissions of the operation at hand; a read across projects narrowed by
each project's own rows, a few projects at a time; a record checked by id
read in the caller's project only.

A guard over every model: a relation filtered by an id or by ids in
Includes is written to its key beside the key's own condition, and no
server code copies a relation's filter over its key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alytics rows through their parent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ject

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ery language

A record with no labels of its own carries the labels of the records it
belongs to or is about, for grants limited to labels and for blocks with
labels, on every operation; a request reaches its own project's records
only; a list across projects narrows each project's records by that
project's permissions; monitor logs, SLO history, network flows and cost
allocations are read through the record they belong to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A grant limited to labels keeps a record with no labels of its own when
  one of the records it belongs to or names carries one of the grant's
  labels, in one condition on the record's id, as a block with labels
  leaves it out when one of them carries a blocked label. A record that
  names none of them stays.
- A read across projects narrows each project by that project's own
  blocks: a block in the project the request names leaves out that
  project's records only.
- A check of one record reads it once for both its block and its grant
  half, with its project, and looks nothing up when the rule adds nothing;
  a record read without its project is answered as missing. The callers
  that read the record themselves select its project.
- Live updates notice a write to a key the label rule follows.
- The permissions page says so in every language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s weigh the parent's read grants

- A delete of a record read through its parent (an alert's note) is
  narrowed by the caller's read grants on the parent, as a read is: the
  parent's delete grants are about deleting the parent.
- AccessControlPermission.checkRecordByModel runs the block and the grant
  check of one record on one read of it; UpdatePermission and
  DeletePermission call it.
- A refusal under several block rows names each of their permissions.
- findOneUpdatableById weighs the label rule on a record with no labels of
  its own through the update scope's query: outside it, the record answers
  nothing, as the method promises and its callers expect.
- hardDeleteBy's DELETE names the rows it found by id with the query's
  column filters; filters on relations stay with the lookup.
- The analytics registry filter is named after what it returns
  (getOwnerTypesOfRead).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ct, as the checks ask

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…caller may read on every write

- A change of a record read through another one (an alert's note) keeps
  to the parents the caller may read, as a read does, and - as it always
  has - to the parents the caller may edit; when both are limited to
  labels, the parent carries one of each. A delete keeps to the parents
  the caller may read.
- A record with no labels of its own is no longer read for its block
  check when the caller cannot look it up under the rule; the query the
  operation runs with applies the rule.
- A refusal under several block rows says one of their permissions holds.
- The record checks say what the fetch must read: the record's project.
- hardDeleteBy's column filter helper no longer carries an unreachable
  branch.
- The declare suite's alert store answers a read with the project only
  when the read asks for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it through conditions on the record's own id

- A change or delete of a record read through another one (a note of an
  incident, an announcement of status pages) is narrowed by conditions on
  the record's own id: one for the parents the caller may read, and on a
  change one for the parents they may edit (ReadPermission
  .addParentLabelsToQuery, QueryHelper.oneLinkedToAny). The caller's own
  filters stay as sent, a record readable through one parent and editable
  through another is reached, and a parent whose model carries no labels
  answers nothing instead of failing the query.
- A read keeps narrowing the relation itself, as before.
- hardDeleteBy's column filters keep no filter of a query per project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simlarsen
simlarsen merged commit 5209b22 into master Oct 7, 2026
5 of 56 checks passed
@simlarsen
simlarsen deleted the claude/label-blocks-everywhere branch October 7, 2026 17:31
@simlarsen

Copy link
Copy Markdown
Contributor Author

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

simlarsen pushed a commit that referenced this pull request Oct 7, 2026
… read finds since #4526

#4525 and #4526 merged the same afternoon. #4526 made grants limited to
labels, or to what a member owns, reach a record with no labels of its
own through what it names: an AI insight about the Blue service is one
only a reader of that service may read. Live updates follow the read
rule, so the Red label reader and the owned-only reader stopped hearing
about that insight - as intended - but #4525's audience suite still
expected them to, and the Postgres Schema Drift workflow failed on
master. The two cases now expect the plain insight only, like the
Blue-blocked reader; the suite's second check (heard equals what the
same reader's read finds) holds for every case.

Verified against a freshly migrated Postgres (all 660 migrations): 23/23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simlarsen pushed a commit that referenced this pull request Oct 7, 2026
…, however the permission layer answers

Postgres Schema Drift went red on master after #4526. Since then the record
rule reaches an alias through its database (CanAccessIfCanReadOn,
OwnedThrough), so the permission layer answers before
DatabaseServerEndpointService's own parent check can name the database:

- a label-scoped caller removing another team's alias is refused with
  "You do not have permission to delete this Database Endpoint.";
- an Owned-scoped caller removing an alias of a database they do not own
  gets a delete narrowed to no row (resolves 0).

Either way the alias stays, which both tests check next. The tests pinned
the service's own message instead, which only a caller who may delete
endpoints but not edit the database still sees (those three cases keep
their exact message). They now assert what the product promises - nothing
is removed - and accept any answer of the permission layer that removes
nothing, since how that layer answers a by-id write outside the caller's
scope is covered by its own suites and is moving to "not found".

The service's note said the parent's label and Owned scopes never reach a
delete; it now says they do, and why the edit-the-database check stays.

Verified on a throwaway migrated Postgres: DatabaseServerSqlPostgres,
DatabaseContainerCommandPostgres and ContainerSnapshotUpsertPostgres,
164/164.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simlarsen pushed a commit that referenced this pull request Oct 7, 2026
…ng a blocked label since #4526

Postgres Schema Drift's Proxmox inventory step failed once the steps in front
of it were fixed: "a label-scoped lookup loads only the permitted labels, so
the block check is given every label - and a block on another label of the
cluster refuses the removal" expected the update-scoped lookup of a cluster
labelled [permitted, blocked] to come back with the permitted label only and
to pass the block check - the weakness that made the remove-node route load
the cluster as root and check every label. Since #4526 the record rule
(BasePermission.addRecordScopeToQuery) applies the team's blocks inside the
query itself, so that lookup finds nothing at all.

The test now shows both halves: on a cluster labelled [permitted, other] the
scoped lookup still loads the permitted label only (why the route checks a
root-loaded cluster), and a cluster carrying a blocked label is not found
through it. The root-loaded check and the route's 404 are unchanged.

Verified on a throwaway migrated Postgres: ProxmoxResourcePostgres 91/91.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant