Skip to content

feat: multi-account user scope isolation#669

Open
kaizhou-lab wants to merge 172 commits into
mainfrom
feat/multi-account-user-scope
Open

feat: multi-account user scope isolation#669
kaizhou-lab wants to merge 172 commits into
mainfrom
feat/multi-account-user-scope

Conversation

@kaizhou-lab

Copy link
Copy Markdown
Contributor

Summary

Introduce per-user data isolation across AionCore so multiple Core users (AionUI local and AionPro external identities) can share one deployment without observing each other's business data, events, or runtime state.

Identity & auth

  • Extend users into a Core User projection: user_type (local/aionpro), external_user_id, status, session_generation
  • AionPro provisions users through a protected, idempotent internal API guarded by a process-level Bootstrap Secret (constant-time compared, never logged or persisted); AionPro mode never falls back to system_default_user
  • Sessions bind session_generation; disabling a user or revoking a session invalidates old tokens and tears down WebSocket connections, team/channel runtimes, conversation runtime, and file/office watches for exactly that user

Data layer

  • Single migration 028_user_scope.sql: aggregate roots and independent config roots gain user_id; pure child tables (messages, artifacts, acp sessions, snapshots, mailbox, team tasks, cron runs) authorize through unbypassable parent JOIN/EXISTS chains
  • Legacy data backfills to system_default_user; only explicit builtin/system template rows stay global (user_id IS NULL) — unknown owners never default to global
  • Preflight integrity checks (orphans, cross-account links, row counts) abort the whole migration transaction on failure
  • Repository user-facing methods take explicit user_id and filter in SQL; unscoped variants are marked *_system and reachable only from scheduler/recovery/startup paths

Runtime boundaries

  • WebSocket handshake resolves the token to an active user; business events are delivered per data.user_id, unscoped events are dropped, and only a 3-entry whitelist broadcasts globally
  • Assistant/Agent-Metadata/Skill use mixed scope (global builtin rows + user rows); a user-owned skill with a builtin's name wins during materialization with no silent builtin fallback
  • Channel gains an owner_user_id boundary; session binding requires the channel user and conversation to share one owner (CROSS_ACCOUNT_REFERENCE on mismatch)
  • Cron jobs persist their owner; scheduler/recovery derive the execution user from the persisted row, never from the foreground account
  • Per-job cron skills (user prompt content) are owned by the job owner: catalog sync no longer ingests them as global rows and resolvers no longer probe cron dirs without an owned repo row
  • Extension enablement is a per-user preference stored by (user_id, extension_name); install/lifecycle stay machine-level

Product decisions

  • Extension files, manifests, and lifecycle remain global; only enablement is per user
  • Complete File/Office filesystem ownership is out of scope: this PR provides user-scoped runtime cleanup only, relying on OS file permissions for the local desktop model

Audit & test hardening

A full completion audit against the design acceptance criteria ran across 8 domains (auth, realtime, conversation/cron, config roots, team/runtime-tools, catalog, channel, migration/extension). It surfaced and fixed one real isolation defect (global cron-skill rows) plus ~25 stale tests that had never run at full-suite scope, and one implementation gap in the assistant locale fallback (fixed in the implementation, not the test).

Verification

  • just push full pre-push gate: migration check, fmt, clippy, workspace tests — 7083 passed, 0 failed (two consecutive full runs)
  • Cross-user coverage includes: A/B isolation per domain, forged body/header user_id rejection, cross-account reference rejection (409/CROSS_ACCOUNT_REFERENCE), unauthenticated 401 / missing-CSRF 403 paths, WS event delivery isolation, revoke teardown per user, migration integrity on fresh and legacy databases

zk added 30 commits July 22, 2026 14:12
zk added 30 commits July 23, 2026 13:17
Office proxy routes require auth on this branch (preview ports are scoped
to the active Core user; unauthenticated access is pinned to 401 by
au2_unauthenticated_all_office_endpoints). The four proxy SSRF/root-path
tests still asserted 403 without credentials and were tripped by the auth
middleware first. They now log in and assert the SSRF port rejection as
authenticated users, keeping their original intent.
The WS handshake now resolves the token's user against the users table
(active row + session generation), so sign_token seeds an active Core
user row before signing instead of minting tokens for nonexistent users.
Fixed post-connect races by replacing fixed sleeps before client_count
assertions and broadcasts with a bounded wait_for_clients poll.
The layered rule-read fallback migrated legacy-named files only in the
user-scoped dir path; the legacy root branch skipped the locale-less
retry, so a locale-specific read served the legacy file via the glob
last-resort without migrating it to the encoded path. Mirror the scoped
fallback in the legacy root branch so the migration side effect applies.
Fixes generated_rule_with_requested_locale_falls_back_to_legacy_locale_less_path.
…er-scope

# Conflicts:
#	crates/aionui-cron/src/service.rs
…er-scope

# Conflicts:
#	crates/aionui-team/src/events.rs
#	crates/aionui-team/src/service.rs
#	crates/aionui-team/src/session.rs
#	crates/aionui-team/tests/session_service_integration.rs
Upgrade path for AionUi -> AionPro over the same database: legacy data
was backfilled to system_default_user by 028_user_scope, so the first
AionPro login used to see an empty library. On provision, while the
caller is the machine's only external user, re-own every user-scoped row
held by system_default_user to it.

- Ownership tables are discovered from the live schema (any table with a
  user_id column) so future migrations cannot be silently missed; a
  sentinel test pins the convention against the core tables.
- Self-idempotent, no marker: the source set empties after adoption, and
  a second provisioned account closes the window permanently.
- UPDATE OR IGNORE skips rows colliding with the new owner's own rows on
  per-user PK/UNIQUE tables (owner's data stays authoritative).
- Global template rows (user_id IS NULL) stay shared.

Also mount a credentialed CORS layer for non-local identity mode
(mirror origin + allow-credentials + x-csrf-token): the desktop renderer
is a cross-origin browser context and preflights were failing with no
Access-Control headers.
…ser-scope

main's 028_project_bind collides with this branch's migration number, and
its new tables carry user data that the multi-account model must scope:

- renumber 028_user_scope.sql -> 029_user_scope.sql (main owns 028);
  migration tests renumbered and legacy setups now include 028 so the
  upgrade path matches production order
- extend 029: rebuild projects with user_id NOT NULL (FK, per-user
  indexes) and project_explorer with a denormalized owner_user_id so the
  one-workspace-per-folder uniqueness becomes per user instead of letting
  one user reserve a directory machine-wide; folders stays global by
  design (canonical paths are machine facts)
- both tables are empty at 029 time (phase 1 is un-wired); DEFAULT
  'system_default_user' keeps phase-1 store code working unchanged
- add cross-scope preflight checks (explorer owner == project owner;
  conversations/teams.project_id owner consistency)
- pin new semantics in migration tests: default-owner insert, per-user
  workspace uniqueness (same folder two owners ok, same owner twice
  rejected), legacy backfill

Phase-2 wiring obligations for the project team are documented in
docs/superpowers/2026-07-23-project-bind-user-scope-addendum.md
…at/multi-account-user-scope

main #609 routes claude/codex through a new direct-CLI SessionAgentTask
(+ the aionui-session state-machine crate) instead of the ACP manager.
The merge preserves that architecture and threads the multi-account user
scope through every persistence/resolution seam it added:

- acp_session repo: main's clear_session_id(conv) -> clear_session_id_for_user
  (conversations parent-chain EXISTS guard); added cross-user rejection test
- session_agent: SessionAgentTask / SessionBuildInputs / build /
  spawn_event_pump / persist_side_effects / spawn_catalog_writeback all
  carry user_id; every acp_session write goes through *_for_user; user_id
  is the conversation owner (never defaulted)
- mcp_resolve: list()/list_by_ids_any() take user_id
- factory/acp: session-port early return passes ctx.user_id; dropped the
  builtin managed-acp helper main removed
- sqlite_conversation: keep BOTH main's transactional recency bump AND the
  user-scope parent guard in insert/upsert_message_once, on a BEGIN
  IMMEDIATE writer-lock-first transaction (fixes concurrent 'database is
  locked')
- runtime_status/system: drop the acp-tool reporters main removed, keep
  the user-scoped runtime status events + tests

The edits are additive scoping only; no turn/protocol/streaming logic was
touched. Verified: no orphaned unscoped repo calls, user_id sourced from
the conversation owner, both parents' semantics coexist.
Targeted suites green (aionui-db + ai-agent + channel 1765/1765; app
message/websocket e2e 53/53); fmt + clippy clean on edited crates.
…nt_id

The logical agent identity previously doubled as the row id, so mixed-scope
rows (global template + per-user copy-on-write overrides) shared the same id
value. Align the table with skills/assistant_definitions: id is now a unique
surrogate row key and agent_id carries the repeatable logical identity, with
the partial unique indexes moved to agent_id. The repository keeps exposing
the logical id (agent_id AS id), so no Rust-facing contract changes.
…ion-scoped runtime tokens

In AionPro mode every /api/* route sits behind JWT auth + CSRF, which the
agent-subprocess helper CLI (aioncore config / diagnose) can never satisfy —
all its calls 401ed and in-conversation /cron was broken.

- Mint a runtime token for EVERY conversation build/rebuild (was team-only)
  with a new conversation:helper scope; team conversations additionally keep
  the team-tools scopes.
- config/diagnose read AIONUI_RUNTIME_TOKEN and send x-aionui-runtime-token.
- auth_middleware gains a second channel: a JWT-less request bearing the
  runtime headers is validated against the token's (user, conversation,
  scope) binding via an IRuntimeTokenVerifier port (wired to
  RuntimeTokenService in the composition layer) and the bound user is
  injected as CurrentUser, so user-scoped handlers work unchanged. Requests
  on this channel are exempt from cookie-based CSRF.
- Tests: middleware channel unit coverage plus an AionPro-mode e2e proving
  the cron helper path passes auth/CSRF, forged or cross-conversation
  headers fail closed, and team-scoped tokens cannot use the channel.
The project-bind wiring merged from main is single-user: SqliteProjectStore
inserted projects/explorer rows without an owner, so every runtime-created
project landed on system_default_user even for AionPro accounts, and reads
were unscoped.

- IProjectStore / SqliteProjectStore: every projects/project_explorer method
  takes the acting user and filters or writes the owner column; folders stay
  a global canonical-path registry by design. Workspace uniqueness is now
  per owner, matching migration 029's (owner_user_id, folder_id) index.
- ProjectService threads user_id through all public methods; attach_folder
  gains an ownership gate so entries cannot be hung off another user's
  project; cross-user rename maps to project_explorer_not_found.
- Conversation/team bind call sites pass the owner.
- Tests: per-user same-folder projects, cross-user get/attach/remove/rename
  not-found coverage; injected-store fixtures seed the acting owner to
  satisfy the users(id) FK.
…for AionPro adoption

adopt_system_default_data discovered ownership tables only by a user_id
column, silently skipping tables whose owner column is owner_user_id
(project_explorer, channel assistant_users/pairing_codes/plugins) — on an
AionUi->AionPro upgrade those rows stayed on system_default_user while their
paired roots moved.

- Adoption now sweeps both ownership column names.
- New adoption-coverage gate (aionui-db tests): every table in the live
  schema must be classified as ownership root / declared parent-scoped
  (parent column verified) / declared machine-global (with rationale) /
  infrastructure. An unclassified new table — including ones merged from
  main — fails the suite until a conscious adoption decision is made.
- Upgrade e2e: first external user adopts the projects/explorer pair
  together; a second external account closes the window in either order.
…r delta table

The mixed-scope agent_metadata catalog let one agent_id have a global template
row plus per-user copy-on-write copies. Machine-level startup probes (unified in
main #678) took the no-acting-user write path, which minted system_default_user
copies on every AionPro boot — the catalog's single source of truth forked, and
identity fields could drift out of sync across copies.

- agent_metadata is now UNIQUE(agent_id): exactly one row per agent (builtin =
  user_id NULL; custom = creator's id as an ownership attribute). No copies.
- New agent_user_state(user_id, agent_id) holds every per-user delta: enabled
  toggle, command/env overrides, session-scoped availability, handshake caches.
  Reads LEFT JOIN and overlay user-first; the availability snapshot is taken
  whole from the winning side so its fields never mix.
- Startup probes UPDATE the catalog row in place (never insert); session
  outcomes / manual checks upsert the user's delta row.
- AionPro startup no longer runs the default-user generated-assistant reconcile
  or ingests the legacy shared skill directory (unattributable files); each real
  user materializes generated assistants lazily. Channel default-assistant
  resolution triggers that lazy materialization via a composition-layer port so
  the bare fallback still resolves.
- Gate e2e: after a full AionPro boot, no ownership column outside users holds a
  system_default_user row. Handshake/binding tests updated to the single-row model.
Replace the SHA256-hash .users/{hash} directory scheme (and the flat
root special-case for the default user) with a type-first layout:
skills/users/{user_dir}/ for every user, including the default user.
user_dir is derived via aionui_common::user_dir_name, keeping the
scheme consistent across domains.

is_user_scoped_skill_storage_path now matches the "users" path
component instead of ".users".
…/{dir}

Thread user_id through the avatar read/write/display path so avatar files
land in and are read from assistant-avatars/users/{user_dir}/. Generated
assistants share bare assistant_ids across users, so a flat avatars dir
collided; per-user dirs fix that. Reconcile and cleanup stay default-user
scoped; response builders pass the acting user.
New auto-provisioned conversation/team workspaces now land under
conversations/users/{user_dir}/{Y}/{M}/{D}/{leaf}. is_auto_workspace and the
delete/prune path accept BOTH the new per-user layout and the legacy userless
one, so pre-existing conversations keep working and are not misclassified as
custom (no historical migration needed — workspace paths are keyed by unique
conversation/team id). Threads user_id through the ConversationPort
create_team_temp_workspace trait.
…Pro adoption

When DB adoption re-owns the local default user's rows to the first external
(AionPro) user, physically move the corresponding per-user files
(skills/assistant-rules/skills/avatars) from users/system_default_user/ to
users/{adopter_dir}/ and rewrite skills.path, so the upgraded account sees its
files. Implemented as aionui_extension::fs_adopt (same-volume rename, EXDEV
copy fallback, idempotent) behind a SystemDefaultFilesystemAdopter port wired
in the composition layer. Best-effort; never fails provisioning.
…ackend

Boots real AppServices in AionPro mode and asserts against the production
filesystem: two users importing the same-named skill get distinct on-disk
roots (skills/users/{dir}) and materialization never crosses users; and
provisioning the first AionPro user physically moves system_default_user's
files to the adopter's root and rewrites skills.path.
…ser layout

The HTTP-driven e2e tests asserted the old flat/encode paths (assistant-rules/{id},
assistant-avatars/{file}, skills path .users/{hash}). Point them at the new
type-first users/{dir} layout; meaning preserved (still assert distinct per-user
on-disk storage and correct file contents).
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