Repository navigation
fix(permissions): one record rule for every read, update and delete, across projects and through the records a record names - #4526
Merged
Conversation
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>
Contributor
Author
✅ Snyk checks have passed. No issues have been found so far.
💻 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):project,projectId, or any relation given as a plain id,{ _id }orIncludesis 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.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.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.ReadPermission.addLabelRulesToQueryis 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.DatabaseService.findWithAccessControlIds(used byupdateOneById,deleteOneByIdandfindOneUpdatableById) names the record in the caller's project, with its project, before reading its labels.AccessControlPermission.checkRecordByModelruns 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.findOneUpdatableByIdanswers 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.@OwnedThrough(..., { onlyParentModels: true })). An AI insight follows the service it names (@OwnedThrough("telemetryServiceId", Service, { includeUnattributed: true })).hardDeleteBydeletes 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
What changes for customers
The permissions page says all of this, in every docs language.
Left out
projectIdwith a plain id reads each of the user's projects in turn; the filter does not pick one of them (anIncludesfilter 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 withproject,projectIdand 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.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 compileandnpm run compile-testspass on the branch with the latest master merged in.packages/App:npm run compilepasses. 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.Tests/Server,Tests/TypesandTests/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),SsoInCommunityEditionandCreateAndReadResponsesKeepIds(billing off), andRealtimeAccessChangesValkey, which needs its own Valkey (the shared one here refuses its credentials).RecordScopeCrudPostgresagainst a Postgres migrated with every migration, master's latest included: 119 tests pass.--fixon every changed file.🤖 Generated with Claude Code