Skip to content

fix(events): check stream identity against the stored account - #214

Merged
rrrodzilla merged 3 commits into
mainfrom
fix/stream-identity-check
Oct 6, 2026
Merged

rrrodzilla merged 3 commits into
mainfrom
fix/stream-identity-check

Conversation

@rrrodzilla

@rrrodzilla rrrodzilla commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Closes #207

Summary

GET /api/v1/forge/schemas/{schema}/events refused every login-minted token that carries a tenant chain with 403 Stream authorization is no longer valid. before sending any event. The live identity check (identity_valid in routes/events.rs) called auth_store.get_user(&claims.sub), but password and OAuth login mint sub = "user:<username>" while the auth store is keyed by the bare username. No local account was ever found, so:

  • tenant-scoped callers (single or multiple memberships, with or without X-Active-Tenant) fell into the external-subject branch, which refuses any non-empty tenant chain;
  • callers without a tenant chain (for example a platform admin with no memberships, or any account under disabled tenancy) were treated as external subjects, so deactivation and role changes never closed their streams.

A second defect sat behind the first: the tenant-scope middleware passes platform-admin claims through unscoped, so an admin's tenant_chain is the flat login-time membership set. The check still treated its last entry as an active tenant and expected a scoped role on top of the admin's roles. That kept an admin with a creator membership (for example the admin who created the first organization under creator_role = "owner") refused even after the subject fix.

Fix

  • routes/auth.rs gains account_username(&Claims), beside refresh, as events: tenant users get 403 and the admin stream skips the account re-check, because the stream looks users up by the prefixed token subject #207 suggests. Refresh, /auth/me, and the stream (identity_valid and the handler's local_account) all resolve the stored account through it. It strips the user: prefix with user_id_from_sub, the mapping Cedar, ownership and creator membership already use. Every token the server mints sets username to that same value, so refresh and /auth/me behave as before.
  • The external-principal arm (Ok(None), no local account, empty chain) is now decided from the resolved username, so it applies only to tokens that really have no User row.
  • Share the middleware's rules instead of re-deriving them: tenant_scope::is_tenant_scoped (platform admins bypass tenancy) and tenant_scope::active_membership_roles (pure; add_active_membership_role now wraps it). The stream compares the token's roles with a pure expected_roles(global, tenant_roles, active) and only walks memberships and hierarchy for tenant-scoped callers.
  • Docs: docs/events.md states that platform admins get account status and role rechecks only. CHANGELOG entry under Unreleased/Fixed, after the fix(cli): validate custom policies against the system schemas #213 entry.

Tests

New tests in crates/schema-forge-acton/tests/entity_events.rs drive the production path: real POST /auth/login, the PasetoAuth middleware serve installs, then tenant_scope. Accounts are seeded the way invite acceptance seeds them.

  • login_tokens_open_streams_for_admins_and_tenant_members: platform admin with no memberships, platform admin with a creator owner membership, single-membership member, and a multi-membership user with scoped owner roles under X-Active-Tenant for each org, all open. The member receives an entity.updated change on its stream, and the scoped owner renames its org through its scoped grant and receives that change.
  • login_streams_close_and_stay_refused_after_membership_changes: removing a membership, or demoting a scoped owner to member, closes the open stream with closed / authorization_changed and refuses reconnects with the old token. The user's other, unchanged scoped grant still opens.
  • login_streams_close_and_stay_refused_after_account_changes: deactivating an admin, or removing platform_admin, closes the open stream the same way and refuses reconnects.
  • Unit tests: account_username maps user:<name> to the stored account; expected_roles adds only the active membership's role; platform admins are never tenant-scoped.

These cover the three tests #207 lists. The existing fixture now uses the production subject (user:alice). That alone makes the existing tenancy and deactivation tests fail without the fix, so they exercise what production sends. Two actor assertions now expect user:alice, which is what production has always emitted. Waits for a close are bounded at 10 s (the idle recheck is at most 5 s), so a regression fails instead of streaming keep-alives until the runner kills it.

Without the fix: the three new tests and membership_removal_closes_stream_and_active_tenant_cannot_be_impersonated plus scoped_membership_role_allows_stream_and_role_revocation_closes_it fail, and idle_keep_alive_and_live_account_revocation never ends (killed after 875 s).

Regression?

No. The same three login-path tests, ported to a v0.48.0 checkout, fail the same way: the member stream is refused with 403, the admin holding a membership is refused, and the deactivated admin's stream stays open. The subject lookup has been wrong since events shipped in 0.48.0 (#206). 0.49.0 changed the role comparison but not the lookup.

Why a 0.48 admin could stream while a 0.49 admin could not: it depends on whether the admin holds a tenant membership, not on how the admin was created. serve --admin-user and schemaforge bootstrap-admin both call shared_auth::bootstrap_admin_with_display_name, which writes the same bare username and ["platform_admin"] with no memberships; only the display name differs. An admin with no membership logs in with an empty tenant chain and lands in the external-subject branch, which opens the stream but skips every recheck. 0.49.0 added [schema_forge.tenancy] creator_role; an admin who creates an organization under creator_role = "owner" gains an owner membership, logs in with a non-empty chain, and is refused. The founder@example.gov fixture models that admin.

Verification

  • cargo nextest run -p schema-forge-cli -p schema-forge-acton --features schema-forge-cli/oauth,schema-forge-cli/sse (the CI SurrealDB job): 1267 passed, 4 skipped (Postgres-only, ignored)
  • cargo clippy -p schema-forge-acton --features postgres,graphql,oauth,sse --all-targets -- -D warnings and cargo clippy -p schema-forge-cli --features oauth,sse --all-targets -- -D warnings: clean
  • cargo test --doc -p schema-forge-acton --features graphql,oauth,sse: clean
  • The stream check is backend-agnostic (a string lookup), so the new tests use in-memory SurrealDB. Adding a Postgres variant to the shared events_http namespace would race the existing Postgres events fixture.

The live identity check behind GET /schemas/{schema}/events looked up
the auth store with the raw token subject. Login and OAuth mint
`sub = "user:<username>"` while the store is keyed by the bare
username, so no local account was ever found. Tenant-scoped callers
fell through to the external-subject branch and were refused with
403 "Stream authorization is no longer valid." before any event, and
callers without a tenant chain were treated as external subjects, so
deactivation and role changes never closed their streams.

Resolve the subject with `user_id_from_sub`, as Cedar, ownership and
creator membership already do. Platform administrators bypass the
tenant-scope middleware, so their `tenant_chain` is the flat
login-time membership set; the check now applies the same rule
through a shared `is_tenant_scoped` helper instead of treating that
set as an active tenant and expecting a scoped role. Expected roles
come from a pure `expected_roles` built on the middleware's
`active_membership_roles`.

The stream fixture now uses the production subject shape, and new
tests drive real password login through the PASETO middleware and
tenant scope: admins with and without memberships, a single-membership
member, and a multi-membership scoped owner open streams, while
membership removal, scoped role demotion, deactivation and global
role changes still close streams and refuse reconnects. Waits for a
close are bounded so a regression fails instead of hanging. The same
tests fail on v0.48.0, so this predates 0.49.0.
Refresh, `/auth/me`, and entity streams now map a token to its stored
account through `account_username`, beside `refresh`, so the stream check
cannot drift from the routes that already resolved the account correctly.
The helper strips the `user:` subject prefix exactly as the Cedar principal
and ownership checks do; every token the server mints sets `username` to
that same value.

Refs #207
A single-membership member and a multi-membership scoped owner now each
receive an `entity.updated` change on a stream opened with a login token,
and the owner's write goes through its scoped grant.

Refs #207
@rrrodzilla
rrrodzilla force-pushed the fix/stream-identity-check branch from 0a5ef1c to 67fee1e Compare October 6, 2026 21:54
@rrrodzilla
rrrodzilla merged commit 74e59ab into main Oct 6, 2026
7 checks passed
rrrodzilla added a commit that referenced this pull request Oct 6, 2026
Bump the crates changed since v0.49.0 by a patch: schema-forge-cli
0.49.1, schema-forge-acton 0.47.1, schema-forge-backend 0.21.1 and
schema-forge-mssql 0.8.1, with the internal version pins on the mssql
backend and the CLI to match. Date the changelog section.

The release carries the entity event stream fix for login tokens and
tenant members (#207, #214), the system-schema check in
`policies validate`, and the acton-service 0.46.0 build (#217).
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.

events: tenant users get 403 and the admin stream skips the account re-check, because the stream looks users up by the prefixed token subject

1 participant