Skip to content

Commit fa9e98b

Browse files
committed
Merge origin/main into claude/project-thread-3fzya4
main gained the EXTERNAL rung's grant stamp inside start()'s authorization middleware (step 2.6), the statement this branch moves, and it edits the @objectstack/core import line this branch also edits. The conflict in security-plugin.ts is resolved by carrying those lines with the statement into security-plugin-authz-middleware.ts: the host gains collectRLSPolicies, which they read, and the three names only they import (isExternalAudienceContext, grantRowPolicySpeaks, stampExternalGrant) move with them. The resulting tree is the one the scripted move gives when it is re-run on the new main. Move-only: no verdict changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BoLT8Gaa8t9a7AWVH8qZa
2 parents 1b2378c + 9c43e19 commit fa9e98b

121 files changed

Lines changed: 10554 additions & 3672 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/plugin-security': minor
5+
'@objectstack/plugin-sharing': minor
6+
'@objectstack/plugin-auth': minor
7+
---
8+
9+
feat: a holder of the `external` organization role is the EXTERNAL principal — the org-wide default never widens it, and it reaches data only through shares, sharing rules and grant RLS
10+
11+
Clause-②: yes (widening: `@objectstack/core` gains `owdForAudience`, `isExternalAudienceContext`, `externalGrantSpeaks` and the two grant-stamp keys; every narrowing below applies only to the `external` role, which no release has shipped yet)
12+
Metadata-protocol: semantic
13+
14+
Stage 2 of the `external` organization role (ruling of record 6355206139). Stage 1 kept externals off the member directory and the everyone-anchored default sets. This stage makes the role a posture of its own:
15+
16+
- **Audience and posture.** The authorization resolver stamps `audience: 'external'` on a holder's execution context, and `derivePosture` returns `EXTERNAL` for it. Platform admin still outranks it; tenant admin does not. The assembled context carries the audience to agents acting for an external, as it carries the posture.
17+
- **The org-wide default.** For an external, every object's org-wide default is owner-only: it reads and writes the rows it owns and the rows shared to it, by a share or a sharing rule. A `public_read` or `public_read_write` object is not open to it. A `controlled_by_parent` detail is not floored on its own: its rows follow the master, which meets the floor, so an external reads the lines of a master shared to it.
18+
- **Grant RLS lifts that floor.** When a permission set or position the external holds carries a row policy for the object (a `using` predicate, such as `supplier in current_user.<key>`), the object's own default stands and that policy bounds the rows. The object's declared default row scope and the platform's ownership floor do not count as a grant.
19+
- **Writes.** `external_default` carries the platform's owner-only write floor (`owner_only_writes`, `owner_only_deletes`), bound to `org_external`, so an external never writes wider than a member. It shares the member floor's identity, so every relief that lifts a member's floor lifts an external's too.
20+
- **Explain and describe** name the baseline an external resolves, `external_default`, as "external baseline (org_external)", and explain's OWD layer reports the owner-only floor when no held grant speaks.
21+
- **Directory doors.** `list-teams` and `get-full-organization`'s `teams` answer an external only the teams it sits on. `list-invitations`, which better-auth gates on membership alone, answers an external only invitations to its own email.
22+
23+
Nothing changes for a principal that does not hold the `external` role.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
feat(lint)!: a membership key, and a row policy that reads one, are judged when authored, at the CLI and at the object and permission save doors
7+
8+
Clause-②: no (narrowing: `os validate`, `os build`, `os lint` and the object and permission save doors refuse a `membershipKeys` declaration that cannot resolve and a row policy that reads a membership key in a way that matches no rows; the RLS rule now also judges object writes)
9+
Metadata-protocol: narrowed
10+
11+
`ObjectSchema.membershipKeys` names a `current_user.KEY` set: the distinct `valueField` values of the roster rows whose `userField` is the signed-in user and on which `filter` holds. The security plugin resolves it per request, and every miss fails closed, so a declaration that cannot resolve, or a policy that reads a key in a position it cannot match, silently grants nothing. Those are now refused where they are written.
12+
13+
**The new rule, `validateMembershipKeyDeclarations`** (gating, every command, and the runtime publish gate for `object` writes), with four ids:
14+
15+
- `membership-key-user-field-invalid`: `userField` is not a column of the roster object, or is not a single-valued user reference (a `user` field, or a `lookup` / `master_detail` to `sys_user`, not `multiple`). The column is judged on the object as merged with its extensions.
16+
- `membership-key-value-field-invalid`: `valueField` is not a column of the roster object, or is not a reference (`lookup`, `master_detail` or `user`).
17+
- `membership-key-declared-twice`: one key declared on two objects. At the save door this reads the live objects the written stack does not carry, outside the package closure too.
18+
- `membership-key-resolver-collision`: a key the registered membership resolver (ADR-0105 D11) also owns. Judged at the save door only, which holds the resolver's `keys`; the CLI cannot see a resolver.
19+
20+
**`validateRlsPredicateEnforceability` learns membership keys**, and now runs for `object` writes at the save door beside `permission` writes:
21+
22+
- A key's `filter` is judged like an RLS clause, with the roster object as its target, at `objects[N].membershipKeys.KEY.filter`. A filter that does not lower, names a column the object lacks, compares with a list-holding column, or is refused by the engine's judge reports the existing `rls-predicate-*` id. A filter reading any `current_user` value reports `rls-predicate-unknown-user-variable`: a key is resolved with no caller.
23+
- New id `rls-predicate-membership-key-target`: a declared key tested against a column whose values can never be the key's (`buyer in current_user.portal_supplier_ids`, where the key holds `srm_supplier` ids and `buyer` is a user). The referencing column, and `id` on the target object itself, are accepted.
24+
- A declared key compared with `==` reports `rls-predicate-unenforceable`, as a kernel list key always has: a key holds a list.
25+
- At the save door only, `x in current_user.KEY` where no kernel key, membership key declaration or registered resolver owns `KEY` reports `rls-predicate-unknown-user-variable`. The CLI keeps accepting it, because a self-hosted resolver it cannot see may own the key.
26+
27+
**`@objectstack/metadata-protocol`:** the publish gate reads the registered membership resolver's `keys` per publish (`RLS_MEMBERSHIP_RESOLVER_SERVICE`), `[]` when none is registered, and hands them to the rules above. A host with no service registry, or a resolver whose `keys` is not a list of strings, judges no ownership, as the CLI does.
28+
29+
**BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. `membershipKeys` itself is unreleased, so the declaration rule narrows nothing shipped; what follows does.
30+
31+
- **FROM:** a permission write whose policy reads `x in current_user.KEY` that nothing owns answered 200, and the policy matched no rows at runtime. An object write was not judged by the RLS rule, so an object whose `defaultRowScope` the rule refuses on the CLI published, and so did an object write that dropped a field or a membership key a stored policy reads.
32+
- **TO:** in publish mode each of those answers `422 INVALID_METADATA` with the issue located at the policy, the default row scope, or the key. The gate is differential: a stored policy that was already failing is not blamed on the write, and a write is refused only for a finding it adds.
33+
- **Unchanged:** the CLI verdict on an unowned key in a membership position (`unknowable`); a policy reading a key a resolver registered at runtime owns; drafts (`mode: 'draft'`), which are never gated; `OS_ALLOW_UNLINTED_METADATA_WRITES=1`, which still turns a refusal into a logged write; stored rows, which are not read, migrated or refused until next saved; and every public export and signature.
34+
- **Remedy:** declare the key on its roster object's `membershipKeys`, or register the resolver that stages it, before publishing the policy; test a key against the column that references the key's target; use `in`, not `==`; keep a key's `filter` to the roster object's own columns. An object write refused for a stored policy is fixed by keeping the field or key that policy reads, or by changing the policy first.
35+
36+
<!-- adr-0087: not-required (no-migration-prescription) Refusals at the CLI and at the object and permission save doors of membership key declarations that cannot resolve and of row policies that read a membership key in a position that matches no rows, plus the RLS rule's crossing to object writes. No authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted; the repair is the author's choice of declaration, resolver, column or operator, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers these verdicts (not already-registered); and the change is a build and door verdict, not a declaration (not runtime-interface-only or type-surface-only). -->
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
feat(plugin-security): a declared membership key is staged per request, so a policy reading `current_user.KEY` reads the roster
8+
9+
Clause-②: no
10+
Metadata-protocol: semantic
11+
12+
`ObjectSchema.membershipKeys` is now applied. The security plugin's request staging (`stageRlsMembership`, the one producer of `ExecutionContext.rlsMembership`) gains the declarative second source beside the registered `rls-membership-resolver`: when a row policy about to compile reads `current_user.KEY` and an object declares `KEY`, the key is resolved from that roster object's rows.
13+
14+
- **What the key holds.** The distinct values of `valueField` on the roster rows whose `userField` is the signed-in user and on which the optional `filter` holds, inside the caller's own organization wall.
15+
- **How it is read.** Lazily and once per request: only a key a policy about to compile reads is resolved, on the read door and the write `check` alike, and one request that reads the key on several objects issues one read. The read is the platform's own system read: the caller needs no read on the roster object, and the caller's posture and audience do not change it (a portal contact and an internal member resolve a key the same way). The roster object's own row policies are not consulted.
16+
- **Every miss fails closed.** No roster row stages an empty set. More than 1,000 values (`MEMBERSHIP_KEY_MAX_VALUES`), a failed read, a `filter` that does not compile for the roster object (a shape that does not lower, a column the object lacks, any `current_user.*`), a key two objects declare, and a key the registered resolver also owns leave the key unresolved. Every policy reading such a key matches no rows, through the compiler's existing warning. A key both sources claim is logged as an error at boot, naming both owners, and again by the request that meets it.
17+
- **Explain.** The object-level `rls` layer names each key the policies read: its label and description, the roster object, `valueField`, `userField`, the filter, and the size it resolved to or why it did not, and the permission sets whose holders can write the roster object (whoever can write a roster row can grant the key). A record-grained rule reads `via: 'membership:KEY'`, and its detail names the roster row that links the record to the principal.
18+
- **`@objectstack/objectql`: an `extend` contribution cannot declare a membership key.** `SchemaRegistry.registerObject` refuses one that carries `membershipKeys`, with `INVALID_METADATA` (`status: 422`) and the error name `ExtensionMembershipKeysRefusedError`, naming the object and the extending package, beside the `defaultRowScope` refusal. The extension merge never carried the key onto the object, so it registered and then never resolved. The manifest path already refused it at authoring.
19+
- **`@objectstack/spec`**: the `membershipKeys` describe and the row-level security docs no longer say the key is not applied, and the liveness row is `live`.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix: `POST /api/v1/packages/:id/publish-drafts` probes dashboard widgets as the publishing caller, so a correct dataset over a permission-gated object is no longer reported as an error-tier `widget_query_failed`
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The build probes that run after a package publish called the analytics service's `queryDataset` with no execution context. Read admission refuses a read with no context, so every dashboard widget bound to a dataset over a permission-gated object came back as `widget_query_failed` ("Access denied: reading … is not permitted for this user"), with a fix telling the author to change a dataset that renders correctly.
12+
13+
- **`@objectstack/runtime`:** the publish-drafts route now passes the caller's execution context to `publishPackageDrafts` on every request. Before, it passed one only when the request named a `backfill`.
14+
- **`@objectstack/metadata-protocol`:** `publishPackageDrafts` passes that context to `runBuildProbes`, which takes a new optional `context`. The widget probe runs `queryDataset` as that caller, the same path the dashboard renderer takes. "Empty although the object has rows" is now judged over the rows that caller can see.
15+
16+
The probe never falls back to a system context. A publisher who cannot read the dataset still gets the error-tier `widget_query_failed`, because that widget will not render for them either.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'create-objectstack': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix(create-objectstack, cli): a scaffolded project pins every `@objectstack/*` dependency to the exact version of the tool that wrote it, not a `^` range
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
On the v18 line a breaking change may ship in a minor or a patch, and the release notes say "Pin exact versions". The scaffolders wrote `^<version>` instead, so the next re-resolve (`npm update`, a deleted lockfile, an install without the lock) could move a new project onto a release that breaks it without anyone deciding to upgrade.
12+
13+
- **`npm create objectstack`** (`create-objectstack`), **`os init`** and **`os create`** (the default standalone placement) now write the scaffolder's own version, exactly, for every `@objectstack/*` entry in the emitted `package.json`: `18.0.0`, not `^18.0.0`. A prerelease is written verbatim (`18.1.0-rc.2`).
14+
- **Unchanged.** `os create --in-repo` still writes `workspace:*`. Third-party ranges (`typescript`, `vitest`, `zod`, `@types/node`) are not touched. Every `@objectstack/*` package is released on one version, so the exact version is published for each of them.
15+
- **An existing project** keeps whatever its `package.json` says. To follow the docs, replace each `^18.x.y` on an `@objectstack/*` entry with the exact version you run (for example `"@objectstack/runtime": "18.0.0"`), then reinstall.

0 commit comments

Comments
 (0)