Skip to content

feat: multi-account user scope isolation - #669

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

feat: multi-account user scope isolation#669
kaizhou-lab wants to merge 193 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 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.
…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.
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