Skip to content

feat: multi-account user scope isolation#669

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

feat: multi-account user scope isolation#669
kaizhou-lab wants to merge 149 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 22, 2026 14:12
…er-scope

# Conflicts:
#	crates/aionui-system/src/client_pref.rs
- migrate legacy cron-source skill rows to system_default_user instead of
  global; only builtin skills stay user_id NULL
- stop syncing cron skill dirs into the machine-level catalog and remove
  the unauthenticated cron-dir fallback from both skill resolvers; cron
  skills resolve only through a repo row owned by the requesting user
- own the skill-row lifecycle in CronService: save_skill upserts an
  owner-scoped row named cron-{job_id}, delete_skill/remove_job soft-delete
  it, and init() reconciles rows from disk per job owner while cleaning
  legacy frontmatter-named duplicates
- make cron insert conversation-owner validation mode-aware: enforced for
  existing-mode rows, skipped for new-conversation rows whose stale anchor
  is re-validated by the executor before dispatch
- fix stale tests never run at full-suite scope: stream_relay skill
  resolver mock now records the user-scoped hook; extension_e2e skill
  listing/import tests updated to user-scoped skill storage
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
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