Skip to content

Commit 0f2f4de

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24579-v18-sweep-round12
2 parents 984ffc5 + c56115b commit 0f2f4de

41 files changed

Lines changed: 2449 additions & 169 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a delegated administrator's position assignment is judged on every permission set the assignment grants, the set of the position's own name included
6+
7+
Clause-②: no
8+
9+
- **The rule (ADR-0090 D12, as amended 2026-10-17).** The sets a position carries are every set an assignment of it grants: the names its definition's `permissionSets` lists and, while the authorization resolver folds held position names into the requested set names, the permission set of the position's own name when the catalog holds one. The organization-level branch already counted that set. The delegated branch now does too, through the same helper.
10+
- **What a delegate is now refused.** A `sys_user_position` insert, update or delete by an `adminScope` holder whose allowlist does not cover the position's own-name set is refused `403 PERMISSION_DENIED`. The message names the set and says it is the set of the position's own name. An own-name set that carries an `adminScope` is held to strict containment like any other distributed set. A delegatable position whose own-name set carries an `adminScope` can no longer be self-delegated.
11+
- **What the delegable-scope report offers.** `GET /api/v1/security/my-delegable-scope` no longer lists a position for a delegate whose allowlist misses that position's own-name set, so the picker offers only what the write admits.
12+
- **Unchanged.** A position with no permission set of its own name is judged on its listed sets alone, as before. Tenant-level administrators and the organization-level branch are untouched, and the resolver's fold itself is not changed. To keep a delegate able to assign such a position, add the own-name set to the scope's `assignablePermissionSets`.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/cli': patch
5+
'@objectstack/service-settings': patch
6+
---
7+
8+
fix(plugin-auth): the auth IP allow-list judges the client address the server established (#24739)
9+
10+
Clause-②: no
11+
12+
The `allowed_ip_ranges` auth setting (ADR-0069 D5) and the `last_login_ip` login audit (D7) now establish a request's client address by the same rule as the inbound rate limiter: `X-Forwarded-For` (its first entry), then `X-Real-IP`, count only when the stack declares `server.trustProxy`. Otherwise the address is the connection's peer address.
13+
14+
**For operators who set `allowed_ip_ranges`:**
15+
16+
- Behind a reverse proxy or load balancer, declare `server: { trustProxy: true }` in the stack before you upgrade. Without it, the allow-list judges the proxy's own address for every request.
17+
- A request whose client address cannot be established (the transport reports no peer address, and no forwarded header is trusted) is now refused with `403 IP_NOT_ALLOWED`. Before, it was admitted. The first such refusal logs a warning that names `allowed_ip_ranges` and `server.trustProxy`.
18+
- `CF-Connecting-IP` is no longer read. Behind Cloudflare, declare `server.trustProxy`, and the `X-Forwarded-For` it sets is used.
19+
- A dual-stack listener reports an IPv4 client in IPv4-mapped form (`::ffff:203.0.113.7`). That address is now matched as the IPv4 address it denotes.
20+
- With `allowed_ip_ranges` unset or empty, nothing changes.
21+
22+
**`last_login_ip`** records the address the allow-list judged, or no address when none could be established. It is no longer read from a forwarded header the deployment does not trust.
23+
24+
**By package:**
25+
26+
- `@objectstack/plugin-auth`: the rule is published as the dependency-free `@objectstack/plugin-auth/client-address` entry (`resolveClientAddress`). `AuthPlugin` and `AuthManager` take a `trustProxy` option. `AuthManager.handleRequest` takes an optional second argument that carries the transport's `remoteAddress`.
27+
- `@objectstack/runtime`: the inbound rate limiter imports the rule from that entry instead of keeping its own copy. Its keys are unchanged.
28+
- `@objectstack/cli`: `objectstack serve` passes the stack's `server.trustProxy` to `AuthPlugin` as well as to the dispatcher. A host that composes `AuthPlugin` itself passes `trustProxy` the same way.
29+
- `@objectstack/service-settings`: the `allowed_ip_ranges` help text says which address is judged.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/plugin-audit': minor
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
fix(plugin-audit,plugin-auth): a refused sign-in and an account lockout each write a `sys_audit_log` row, as a successful sign-in already does
7+
8+
Clause-②: no
9+
10+
- **Two new ledger actions, each with its writer.** `sys_audit_log.action` gains `login_failed` and `account_locked`. A refused `/sign-in/email` that names an existing account writes one `login_failed` row. The refused sign-in that stamps the account lockout (`lockoutThreshold`, ADR-0069 D2) also writes one `account_locked` row. A sign-in refused while the account is locked writes `login_failed` with the reason `ACCOUNT_LOCKED`. A successful sign-in still writes `login`, unchanged.
11+
- **What each row records.** `user_id` is the account the sign-in named, and the row names `sys_user` and that account's id as its target. The outcome is the action, plus the refusal's machine code in `metadata` (`INVALID_EMAIL_OR_PASSWORD`, `ACCOUNT_LOCKED`, …), and the lock's `locked_until` and `threshold` on an `account_locked` row. `created_at` is the time. `ip_address` and `user_agent` are resolved by the rule better-auth applies to a session row, which is where a `login` row's values come from. `actor` is null, because a refused attempt authenticated nobody. A row never carries the attempted credential, and an identifier that resolves to no account writes no row.
12+
- **Where they show.** Setup → Audit Log's *Auth* preset (the `auth_events` list view) now lists `login`, `logout`, `login_failed` and `account_locked`. Each row is about the account it names, so the ledger's record read gate judges it by that `sys_user` record, as for any row about a user. It carries no `tenant_id`: a refused attempt has no session to take an organization from, so it is a deployment-level row under the ledger's organization scope.
13+
- **Recorded with or without a lockout.** A refused sign-in writes its row whether or not `lockoutThreshold` is set.
14+
- **`@objectstack/plugin-audit` types.** `AuthSessionAuditEvent.action` widens to `AuthEventAuditAction` (`'login' | 'logout' | 'login_failed' | 'account_locked'`). `AuthEventAuditAction` and `AuthAccountAuditAction` are new exported types, and `AuthSessionAuditAction` is unchanged.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Opt-in pre-merge 18.x upgrade harness (#24522, step 0 of the 18.x line).
2+
#
3+
# ## The gap this closes
4+
#
5+
# Ruling record 6266750393 on #15194 makes `main` the release line: every PR on
6+
# the 18.x line, once merged, leaves `main` releasable, and its R2 reads "a
7+
# database an 18.0 install created upgrades to that commit, boots, and keeps
8+
# its data" — a check each of the line's PRs passes BEFORE it merges. The
9+
# harness that answers R2 is a dogfood test,
10+
# `packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts`, and the
11+
# dogfood jobs in `ci.yml` run only on `main`'s full battery (THE TWO SPEEDS,
12+
# #22998): they skip `pull_request` and `merge_group`. So without this workflow
13+
# the line's PRs get their first R2 answer after the merge.
14+
#
15+
# This workflow adds the missing TRIGGER for that ONE file, and nothing else.
16+
# The test, its fixture and its assertions stay where they are, and the full
17+
# battery keeps running the same file with the rest of dogfood on `main`.
18+
#
19+
# ## Opt-in, label-gated — the cheapest wiring measured
20+
#
21+
# Measured on the step-0 branch (the card's report carries the readings):
22+
#
23+
# - the harness file alone: ~54 s of vitest wall (one 4-vCPU box, ~43 s of it
24+
# module import), against the dogfood suite's ~5 min per shard × 3;
25+
# - its build closure is the dogfood package's (`turbo run build
26+
# --filter=@objectstack/dogfood`, 66 tasks), which the turbo cache below
27+
# replays; cold, it is the step's whole cost.
28+
#
29+
# Three shapes were weighed. Running it on every PR taxes the whole repository
30+
# for one release line. Dispatching `ci.yml` by hand on a line PR's branch needs
31+
# no diff but runs the entire full battery (three dogfood shards, Temporal
32+
# Conformance, the console pin) for one file, and is a manual act with no
33+
# trigger a PR carries. The label lane runs one file, only where asked: the
34+
# shape `pack-smoke-optin.yml` already ruled for a pre-merge check that most
35+
# PRs do not need. The epic seat hangs `needs:upgrade-harness` on each 18.x
36+
# line PR; ⛔ never auto-applied, never in `.github/labeler.yml`.
37+
#
38+
# ## Wiring decisions (the `pack-smoke-optin.yml` ones, for the same reasons)
39+
#
40+
# `types:` restates GitHub's defaults beside `labeled` (naming `types:`
41+
# REPLACES the default set). The guard has two limbs: the PR carries the label
42+
# now, and on a `labeled` event the label just added is OURS — otherwise every
43+
# size label would start another run of an unchanged tree. `concurrency` sits at
44+
# JOB level, so a skipped run never cancels a real one. ⛔ No `merge_group:`
45+
# trigger: a `merge_group` event carries no pull request, so the label cannot be
46+
# read there, and this context is NOT a required one
47+
# (`scripts/check-required-contexts.mjs`) — the seat reads it before it queues a
48+
# line PR, the way the ruling's R2 asks.
49+
50+
name: Upgrade Harness (opt-in)
51+
52+
on:
53+
pull_request:
54+
branches:
55+
- main
56+
types: [opened, synchronize, reopened, labeled]
57+
58+
permissions:
59+
contents: read
60+
61+
jobs:
62+
upgrade-harness:
63+
name: 18.x upgrade harness (opt-in)
64+
if: >-
65+
contains(github.event.pull_request.labels.*.name, 'needs:upgrade-harness')
66+
&& (github.event.action != 'labeled' || github.event.label.name == 'needs:upgrade-harness')
67+
runs-on: ubuntu-latest
68+
timeout-minutes: 30
69+
concurrency:
70+
group: upgrade-harness-optin-${{ github.event.pull_request.number }}
71+
cancel-in-progress: true
72+
steps:
73+
# No `ref:` — the MERGE PREVIEW: what `main` will contain once this PR
74+
# lands, which is the commit R2 is a statement about.
75+
- name: Checkout the merge preview
76+
uses: actions/checkout@v7
77+
78+
- name: Setup Node.js
79+
uses: actions/setup-node@v7
80+
with:
81+
node-version: '22'
82+
83+
- name: Setup pnpm
84+
uses: ./.github/actions/setup-pnpm
85+
86+
- name: Get pnpm store directory
87+
shell: bash
88+
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
89+
90+
- name: Setup pnpm cache
91+
uses: actions/cache@v6
92+
with:
93+
path: ${{ env.STORE_PATH }}
94+
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
95+
restore-keys: |
96+
${{ runner.os }}-pnpm-store-v3-
97+
98+
# Restore-only: the dogfood job's entries (seeded from `main`) replay this
99+
# closure; a PR never writes the cache, as in ci.yml.
100+
- name: Restore Turbo cache
101+
uses: actions/cache/restore@v6
102+
with:
103+
path: .turbo/cache
104+
key: ${{ runner.os }}-turbo-dogfood-1-main-${{ github.sha }}
105+
restore-keys: |
106+
${{ runner.os }}-turbo-dogfood-1-main-
107+
${{ runner.os }}-turbo-dogfood-
108+
109+
- name: Install dependencies
110+
run: pnpm install --frozen-lockfile
111+
112+
- name: Build the dogfood package's dependency closure
113+
run: pnpm turbo run build --filter=@objectstack/dogfood --concurrency=4
114+
115+
# One file, named: the dogfood config's filter preflight refuses a path
116+
# that selects no test, so a renamed harness reds here instead of
117+
# passing over nothing.
118+
- name: Boot HEAD on the 18.0 fixture (R2) and check the v17 refusal (R3)
119+
run: pnpm --filter @objectstack/dogfood exec vitest run test/upgrade-18x-harness.dogfood.test.ts

‎content/docs/kernel/runtime-services/audit-service.mdx‎

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: services.audit — the audit log service API
33
navTitle: services.audit
4-
description: Write ingress for audit events that are not CRUD — today, auth session events.
4+
description: Write ingress for audit events that are not CRUD — today, auth session events and sign-in outcome events on an account.
55
---
66

77
- **Stability:** `experimental`
@@ -10,7 +10,8 @@ description: Write ingress for audit events that are not CRUD — today, auth se
1010

1111
`services.audit` is the kernel service slot that `@objectstack/plugin-audit` registers
1212
during `init()`. It is the **write ingress for audit events that are not CRUD** — today,
13-
auth session events — and every call appends one row to `sys_audit_log`.
13+
auth session events and sign-in outcome events on an account — and every call appends one
14+
row to `sys_audit_log`.
1415

1516
Record-level `create` / `update` / `delete` rows do **not** travel through this slot. The
1617
same plugin writes those from ObjectQL lifecycle hooks, with no service call involved, so
@@ -27,7 +28,7 @@ The slot's entire surface is one method:
2728

2829
```ts
2930
services.audit.recordAuthEvent(event: {
30-
action: 'login' | 'logout';
31+
action: 'login' | 'logout' | 'login_failed' | 'account_locked';
3132
userId: string;
3233
sessionId?: string;
3334
organizationId?: string;
@@ -43,18 +44,32 @@ services.audit.recordAuthEvent(event: {
4344
validates nothing in either direction — a misspelled action would be accepted silently.
4445
This union is the only structural protection an author gets, which is why the caller
4546
hands over an **event** (what happened) rather than a row (what to store).
47+
- `login` / `logout` are **session events**: a sign-in that minted a session, and a
48+
sign-out.
49+
- `login_failed` / `account_locked` are **account events**: a refused password sign-in
50+
that named an existing account, and the refused sign-in that stamped the account
51+
lockout (`lockoutThreshold`, ADR-0069 D2). They exist without any session.
4652
- **`userId`** is required and must be a real `sys_user` id — it lands on the strict
47-
`user_id` lookup.
48-
- **`sessionId`** lands on `record_id`, with `object_name` fixed to `sys_session`, so the
49-
ledger row is navigable to the session it describes.
53+
`user_id` lookup. On an account event it is the account the sign-in named.
54+
- **`sessionId`** lands on `record_id`, with `object_name` set to `sys_session`, so the
55+
ledger row is navigable to the session it describes. An account event names its account
56+
instead — `object_name` `sys_user` and `record_id` the account's id — and does not read
57+
`sessionId`.
5058
- **`organizationId`** is the session's active organization. It stamps the row's tenant
5159
columns, and an unstamped row is one that non-administrator members can never see
5260
through the security layer's predicate.
61+
- **`ipAddress`** / **`userAgent`** are the client fingerprint by better-auth's own rule:
62+
the session row's values for a session event, and the same resolution applied to the
63+
sign-in request for an account event.
5364
- **`actor`** is the principal that *caused* the event when that is not the subject — an
5465
administrator's user id on an impersonation session. Omitted, the subject is recorded as
55-
having acted for themselves.
66+
having acted for themselves on a session event; an account event records no principal,
67+
because a refused attempt authenticated nobody.
5668
- **`context`** is free-form and is serialized into `metadata`, e.g. the endpoint that
57-
produced the event (`/sign-in/email`, `/sign-out`).
69+
produced the event (`/sign-in/email`, `/sign-out`). On an account event it is the
70+
endpoint, the refusal's machine code (`reason`, e.g. `INVALID_EMAIL_OR_PASSWORD` or
71+
`ACCOUNT_LOCKED`) and, for `account_locked`, the lock's `locked_until` and `threshold`.
72+
It never carries the attempted credential.
5873

5974
## Notes
6075

@@ -64,8 +79,9 @@ services.audit.recordAuthEvent(event: {
6479
`kernel:ready`. The sink therefore resolves its engine per call, so it is safe to hold a
6580
reference from the moment the plugin initializes.
6681
- `@objectstack/plugin-auth` is the only in-repo consumer. It resolves the slot lazily
67-
through a locally-declared structural surface and calls it from the session lifecycle,
68-
so neither package depends on the other.
82+
through a locally-declared structural surface and calls it from the session lifecycle
83+
and from the password sign-in outcome, so neither package depends on the other. A
84+
sign-in identifier that resolves to no account produces no call.
6985

7086
## Typical Errors
7187

@@ -76,11 +92,12 @@ caller's side:
7692

7793
- A failed ledger insert is reported at `error` level **once per cause** — a cause is the
7894
error's `code` plus the object the row is about, so every uncoded failure collapses into
79-
one bucket and the object is always `sys_session` on this seam — and the row is simply
80-
lost. Nothing retries it. A repeat of an **already-reported** cause drops to `debug`; a
81-
**new** cause gets its own `error` line, once. The sign-in itself succeeded and returned
82-
200, so the shipped `auth_events` list view and the system-overview widgets keep showing
83-
an empty, healthy-looking screen.
95+
one bucket per object, and the object is `sys_session` for a session event and
96+
`sys_user` for an account event — and the row is simply lost. Nothing retries it. A
97+
repeat of an **already-reported** cause drops to `debug`; a **new** cause gets its own
98+
`error` line, once. The sign-in was answered exactly as it would have been without the
99+
ledger, so the shipped `auth_events` list view and the system-overview widgets keep
100+
showing an empty, healthy-looking screen.
84101
- The call **silently no-ops** when no data engine resolves, or when `userId` is absent.
85102
A caller that passes an event without a subject records nothing and is told nothing.
86103
- The most common cause of a failing insert is a datasource split rather than a broken

‎content/docs/permissions/authentication.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ often before the account-level lock engages.
590590

591591
| Setting | Effect |
592592
|---|---|
593-
| `allowed_ip_ranges` | Restrict authenticated access to one or more IPv4 CIDR ranges. IP is extracted with trust-proxy awareness (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`); requests whose IP can't be determined fail open. |
593+
| `allowed_ip_ranges` | Restrict authenticated access to one or more IPv4 CIDR ranges (or exact IPs). The address judged is the one the server establishes, by the same rule as the inbound rate limiter: `X-Forwarded-For` / `X-Real-IP` only when the stack declares `server.trustProxy`, the connection's peer address otherwise. Behind a reverse proxy, declare `server.trustProxy`, or the allow-list judges the proxy's own address. A request whose address cannot be established is refused with `403 IP_NOT_ALLOWED`, like one outside the ranges. `last_login_ip` records the same address. |
594594

595595
> **How enforcement works.** Password expiry (an administrator-forced password
596596
> change included) and enforced MFA share a single *session-validation gate*.

‎packages/cli/src/commands/serve.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4255,6 +4255,12 @@ export default class Serve extends Command {
42554255
appName: resolveDeploymentAppName((config as any).email ?? {}, process.env, (config as any).appName),
42564256
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
42574257
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
4258+
// The stack's `server.trustProxy`, the same authored value the
4259+
// dispatcher's inbound rate limiter receives below: whether the
4260+
// ADR-0069 D5 IP allow-list and the D7 login audit may believe
4261+
// X-Forwarded-For / X-Real-IP when establishing a client address.
4262+
// Undeclared, both judge the transport's peer address.
4263+
trustProxy: (config as any).server?.trustProxy === true,
42584264
// Enable the admin plugin by default so the Setup app's
42594265
// ban/unban/set-password/impersonate/set-role row actions
42604266
// resolve to real endpoints. The plugin self-gates by role

0 commit comments

Comments
 (0)