feat: multi-account user scope isolation#669
Open
kaizhou-lab wants to merge 172 commits into
Open
Conversation
added 30 commits
July 22, 2026 14:12
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.
…into feat/multi-account-user-scope
…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.
…) into feat/multi-account-user-scope
…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).
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.
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
usersinto a Core User projection:user_type(local/aionpro),external_user_id,status,session_generationsystem_default_usersession_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 userData layer
028_user_scope.sql: aggregate roots and independent config roots gainuser_id; pure child tables (messages, artifacts, acp sessions, snapshots, mailbox, team tasks, cron runs) authorize through unbypassable parent JOIN/EXISTS chainssystem_default_user; only explicit builtin/system template rows stay global (user_id IS NULL) — unknown owners never default to globaluser_idand filter in SQL; unscoped variants are marked*_systemand reachable only from scheduler/recovery/startup pathsRuntime boundaries
data.user_id, unscoped events are dropped, and only a 3-entry whitelist broadcasts globallyowner_user_idboundary; session binding requires the channel user and conversation to share one owner (CROSS_ACCOUNT_REFERENCEon mismatch)(user_id, extension_name); install/lifecycle stay machine-levelProduct decisions
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 pushfull pre-push gate: migration check, fmt, clippy, workspace tests — 7083 passed, 0 failed (two consecutive full runs)user_idrejection, 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