feat: multi-account user scope isolation - #669
Open
kaizhou-lab wants to merge 193 commits into
Open
Conversation
added 30 commits
July 22, 2026 14:12
added 30 commits
July 24, 2026 20:00
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).
…tests Left dangling after the single-row refactor removed the custom_params test helper; tripped -W unused-imports / CI.
…t state table An agent is a machine-level resource: one CLI binary + login per device, shared by every Core User. Its enable/disable state gates whether the registry starts the agent (machine-level singleton reads it from the catalog), so it belongs on the single agent_metadata catalog row, not a per-user overlay. The frontend has no agent-enable entry point; per-user enable/disable happens one layer up on assistants (assistant_overrides). - 029 migration: remove the agent_overrides (per-user delta) table; the catalog row holds ALL agent state (capabilities, availability probe, command/env overrides, enabled). - sqlite_agent_metadata: drop the per-user LEFT JOIN from the read projection (AGENT_METADATA_FROM); enabled comes straight from the catalog. set_enabled_for_user now UPDATEs the catalog after an owner-visibility check; delete no longer cleans a per-user table. - registry: handshake writes always target the catalog (no system_default_user special-case). - custom service: reload_one after enable/delete/upsert is now unconditional (machine-level cache refresh), fixing stale machine cache when a non-default user toggled a builtin agent. Full workspace: 7682 passed.
Review doc: docs/superpowers/2026-07-27-multi-account-user-scope-review.md
Must-fix 1 — adoption file-move can now re-run after a partial failure:
- IUserRepository::is_sole_external_user exposes the adoption-window
predicate (sole external user == the caller).
- AuthProvisionService gates fs_adopter on the window predicate instead of
"rows adopted this call", so leftover files under
users/system_default_user/ are picked up on a later login. The window
closes with a second external user, so files can never leak.
Must-fix 2 — is_auto_workspace matches by path structure:
- session_context::is_auto_workspace now wildcards the users/{dir} segment
and the {Y}/{M}/{D} date (leaf still carries the globally-unique
conversation id), mirroring the delete-side matcher. Fixes two
misclassifications: workspaces created on an earlier day, and adopted
workspaces still under users/system_default_user/.
Channel sessions (review §3.1):
- get_or_create_session INSERT now requires a bound conversation to belong
to the same Core owner (NULL stays allowed); new cross-account test.
- adoption_coverage gate grows a NON_CORE_USER_ID_TABLES category so
assistant_sessions.user_id (a channel-user FK, not a Core owner) is a
conscious classification instead of a false positive.
Dead-code removal (review §4.1, verified no callers incl. AionUi/aionpro):
- Drop ICronRepository::{update_system, delete_system,
delete_by_conversation_system}; tests moved to the _for_user paths.
Conversation-delete cascade is handled by the DB FK, not the removed
method.
Fallback observability (review §4.3-4.5, kept per decision):
- The no-repo skill discovery fallback and the six no-dispatcher
assistant-rule/skill route fallbacks now log a warn when taken; they are
test/degraded-mode paths that production wiring never hits.
Review §6 (docs/superpowers/2026-07-27-multi-account-user-scope-review.md):
- Real cleanup-hook e2e (new session_revoke_cleanup_e2e.rs): boots the
production router (real session_revoked_hook, not an injected double),
revokes an AionPro session over HTTP, and asserts observable cleanup —
the revoked token stops working and the user's channel sessions are
cleared from the DB by the hook's async task (polled). Legs that would
need live runtimes/websockets to observe are listed as not-covered in
the test header.
- CROSS_ACCOUNT_REFERENCE over HTTP (cron_e2e): user B creates a cron job
bound to user A's conversation and gets a real 409 with the exact error
code; no job row leaks to either user.
- Two-user filesystem isolation: avatars (assistants_e2e) land under each
owner's assistant-avatars/users/{dir}/ with their own bytes and per-user
serving, and cross-user assistant-id reuse is rejected; auto-provisioned
conversation workspaces (conversation_e2e) live under distinct
conversations/users/{dir}/ roots on disk.
Also updates gitignored design docs under docs/superpowers/ (not committed):
matrix rows 9/22 + §6.3 in the 2026-07-17 root-scope design (agent is
machine-level incl. enabled; cron_jobs is an ownership root), the
2026-07-24 fs-isolation design (delete keeps files; adoption file-move
reinstated with window-gated re-run; invalid-id rejection dropped), and a
disposition table appended to the review doc itself.
…account-user-scope
…at/multi-account-user-scope
…feat/multi-account-user-scope
…i-account-user-scope
…reads origin/main's slash-catalog resolution (merged in 45d4e71) calls IConversationRepository::get / get_assistant_snapshot, whose signatures carry a user_id on this branch (user-scope). The merge was textually clean but broke the aionui-app build (E0061 x2) — caught by PR #669 CI. Resolve the conversation owner via the adapter's existing owner_user_id() helper (best-effort: unresolvable owner yields None, matching the rest of this path).
clippy items_after_test_module: the 45d4e71 merge left work_source.rs's Display impl after mod tests; reorder so the test module closes the file.
…d as 029 main merged PR #700 whose migration took slot 029; move 029_user_scope.sql to 030_user_scope.sql (content unchanged) and update the test's migration version references accordingly. Completes the rename already staged in the working tree.
…agent origin/main published 029_add_mimo_code_builtin_acp_agent (merged in 3a2d7e4), colliding with this branch's 029_user_scope. Main's shipped migration is immutable, so user_scope yields (same ruling as the 07-23 renumber): mimo (old-schema INSERT) runs first, then user_scope's table rebuild carries the row into the new shape — the order is naturally correct for fresh databases. Also adapts two semantic merge conflicts in main's new tests (missing user_id argument to SessionAgentTask::new on this branch's user-scoped signature), renames the migration_029_* test fns to migration_030_*, and fixes the long-standing "Migration 028" header typo in the user_scope file. Verified: workspace clippy --all-targets clean; db + ai-agent + conversation 1922 passed; migration immutability check passed.
…ti-account-user-scope
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