Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
540fa59
feat(auth): add owner-aware OAuth storage foundation
bsbds Aug 20, 2026
6f6c67a
fix(auth): preserve OAuth callback commit boundary
bsbds Aug 20, 2026
3e2bf5f
fix(auth): refresh scoped OAuth lookups
bsbds Aug 20, 2026
c8cb2a3
fix(auth): refresh OAuth foreign-key lookups
bsbds Aug 20, 2026
83a08ad
test(migrations): clarify PostgreSQL index creation coverage
bsbds Aug 20, 2026
73ec857
docs(migrations): cover partial OAuth schema recovery
bsbds Aug 20, 2026
fb056da
test(migrations): describe PostgreSQL ordering scope
bsbds Aug 20, 2026
a8ddb1f
test(auth): cover cross-user OAuth isolation
bsbds Aug 20, 2026
1486596
docs(auth): explain OAuth bulk-delete session state
bsbds Aug 20, 2026
2e58987
docs(auth): remove misplaced OAuth callback note
bsbds Aug 20, 2026
5a124bb
docs(deployment): clarify nullable OAuth identity semantics
bsbds Aug 20, 2026
12a079f
test(mcp): assert hidden actor OAuth response state
bsbds Aug 20, 2026
f207b9b
test(auth): verify create-all OAuth uniqueness
bsbds Aug 20, 2026
f65ebb4
fix(gmail): warn on owner-mismatched watch state
bsbds Aug 20, 2026
eddd27d
docs(deployment): add OAuth index recovery steps
bsbds Aug 20, 2026
4ddf9dd
fix(gmail): capture account owner before transition lock
bsbds Aug 20, 2026
53b5f2a
fix(gmail): warn when background account is unavailable
bsbds Aug 20, 2026
33a1914
test(auth): prove owner indexes are partial
bsbds Aug 20, 2026
2716ae1
fix(auth): normalize OAuth query user IDs
bsbds Aug 20, 2026
04d47ad
fix(migrations): detect SQLite owner name collisions
bsbds Aug 20, 2026
296c307
refactor(auth): defer unused OAuth lookup index
bsbds Aug 20, 2026
c1cc8e6
test(migrations): verify PostgreSQL owner index predicates
bsbds Aug 20, 2026
60e5305
ci(migrations): run owner OAuth PostgreSQL tests
bsbds Aug 20, 2026
5b3872c
docs(auth): explain actor OAuth delete cascade
bsbds Aug 20, 2026
4fc6aca
docs(migrations): add SQLite interruption recovery
bsbds Aug 20, 2026
aaecb39
docs(deployment): warn about old worker restarts
bsbds Aug 20, 2026
54e0cb3
docs(deployment): describe PostgreSQL OAuth lock scope
bsbds Aug 20, 2026
4c33957
docs(deployment): verify OAuth index definitions
bsbds Aug 20, 2026
d78d53e
docs(migrations): distinguish SQLite migration guards
bsbds Aug 20, 2026
6da8afe
docs(migrations): generalize SQLite name recovery
bsbds Aug 20, 2026
f22ce04
chore: merge origin main into OAuth owner foundation
bsbds Aug 20, 2026
7b0c2d4
test(migrations): align PostgreSQL assertions with merge graph
bsbds Aug 20, 2026
efe77d2
fix(auth): reject malformed OAuth state owners
bsbds Aug 20, 2026
930bde9
fix(migrations): preserve single-head OAuth rollback
bsbds Aug 21, 2026
816d997
fix(auth): isolate actor OAuth backrefs
bsbds Aug 21, 2026
76764c0
fix(gmail): signal OAuth ownership mismatches
bsbds Aug 21, 2026
daab4e0
fix(gmail): preserve mismatched watch state
bsbds Aug 21, 2026
e1611eb
docs(migrations): guard SQLite watch rows
bsbds Aug 21, 2026
86421fb
fix(gmail): scope watch OAuth relationship
bsbds Aug 21, 2026
259ce9d
refactor(auth): require scoped delete providers
bsbds Aug 21, 2026
8b8392e
test(auth): verify production SQLite cascades
bsbds Aug 21, 2026
339f36c
docs(auth): explain OAuth owner convention
bsbds Aug 21, 2026
07c0d00
fix(gmail): preserve ownership mismatch cursors
bsbds Aug 21, 2026
a5511d3
fix(gmail): enforce watch ownership lifecycle
bsbds Aug 21, 2026
8598f8a
fix(sqlite): require foreign key enforcement
bsbds Aug 21, 2026
2a7454e
docs(migrations): add SQLite rollback recovery
bsbds Aug 21, 2026
9b94f61
fix(migrations): validate OAuth owner column
bsbds Aug 21, 2026
493902d
docs(migrations): detect orphan Gmail watches
bsbds Aug 21, 2026
d4a9b88
ci(migrations): trigger OAuth owner checks
bsbds Aug 21, 2026
5b1bb5d
chore: merge upstream main and align OAuth migration
bsbds Aug 21, 2026
a8718e8
chore: merge GitHub connector migration head
bsbds Aug 21, 2026
8da9edd
refactor(gmail): split watch lifecycle from OAuth foundation
bsbds Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/test-migrations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ on:
- 'tests/web/services/test_task_interaction_close_postgresql.py'
- 'src/xagent/web/models/chat_message.py'
- 'src/xagent/web/models/user.py'
- 'src/xagent/web/models/user_oauth.py'
- 'tests/web/test_user_oauth_actor_ownership.py'
- 'tests/shared/postgres_disposable.py'
- 'tests/web/services/checkpoint_anchor_shared.py'
pull_request:
Expand Down Expand Up @@ -128,6 +130,8 @@ jobs:
tests/web/services/test_task_interaction_close_postgresql.py
src/xagent/web/models/chat_message.py
src/xagent/web/models/user.py
src/xagent/web/models/user_oauth.py
tests/web/test_user_oauth_actor_ownership.py
tests/shared/postgres_disposable.py
tests/web/services/checkpoint_anchor_shared.py
)
Expand Down Expand Up @@ -322,6 +326,13 @@ jobs:
# added to this list -- the fast job deselects the marker and
# provisions no database, so an unlisted file's postgres tests run
# nowhere.
- name: Test owner-aware OAuth migrations (Postgres-only)
if: needs.detect-migration-changes.outputs.should-test == 'true'
run: |
pytest tests/migrations/test_migration_integration.py -m postgresql -q
env:
DATABASE_URL: postgresql://xagent:xagent@localhost:5432/xagent_test

- name: Test Gmail provisioning Postgres-only behavior
if: needs.detect-migration-changes.outputs.should-test == 'true'
run: |
Expand Down
118 changes: 118 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,121 @@ If one task must recover before its policy inconsistency can be repaired, quiesc
Gate new widget and shared-link task creation before rolling back any worker. Roll back all API and task-execution workers together. Do not re-enable public task creation while versions are mixed.

Marked tasks do not remain isolated when executed by an older worker. Keep public execution gated during rollback, or complete the forward rollout before those tasks resume.

## 2026-08-18 — Owner-aware builtin OAuth storage

### Deployment impact

The `user_oauth` table gets a nullable `resource_owner_key` column. Existing rows keep a null value, and every existing OAuth consumer explicitly selects that ordinary namespace.

Two partial unique indexes replace `uq_user_provider_account`. One index protects ordinary rows. The other separates actor-owned namespaces when `provider_user_id` is non-null. Standard SQL null semantics still permit multiple rows with the same actor key, provider, and null `provider_user_id`. SQLite and PostgreSQL are the only supported database dialects for this schema; startup and migration fail before schema creation on other dialects.

On PostgreSQL the migration creates the replacement indexes transactionally before removing the old unique constraint. A failed statement rolls back the complete schema transition. If a same-name relation causes the failure, an operator must inspect and remove or rename that relation before retrying `alembic upgrade head`. `ADD COLUMN` and the non-concurrent index builds hold table locks until the transaction commits and can block both reads and writes to `user_oauth`. Pause every OAuth operation that accesses this table for the migration window, and monitor lock wait time instead of assuming the pause will be short.

On SQLite the migration rejects globally colliding owner-index names before rebuilding the table in batch mode. Stop every worker before this rebuild and keep SQLite quiesced until the migration completes. Take and verify a database backup before the rebuild: under the driver's legacy transaction mode, SQLite DDL can commit independently of Alembic's outer transaction.

If the SQLite migration process exits after the rebuild starts, keep every worker stopped. Restore the verified pre-migration backup, confirm that `uq_user_provider_account` still exists and `resource_owner_key` does not, and then retry the migration. Do not resume from a table that has lost the old constraint but does not have both owner-aware indexes; that state has no uniqueness enforcement.

The normal application-startup migration path disables SQLite foreign-key enforcement around batch rebuilds and rejects any new foreign-key violations before commit. The standalone `alembic upgrade head` path does not provide that guard. If operators must use the standalone command, record the complete `PRAGMA foreign_key_check;` result and `SELECT count(*) FROM gmail_watch_states;` before and after migration. Do not start workers if the foreign-key result gains a row or the watch-state count changes. The row count is required because a valid `ON DELETE CASCADE` can remove child rows without leaving a foreign-key violation.

If the migration reports `UserOAuth schema is partially owner-aware`, do not start workers. The schema has either `resource_owner_key` and the old `uq_user_provider_account` constraint together, or neither one. Restore the last known complete schema from backup, or have a database operator finish one coherent legacy or owner-aware schema before retrying `alembic upgrade head`. Do not bypass this fail-closed check.

If SQLite reports that an owner-aware schema name already exists before migration, query `sqlite_master` for that exact name and identify its relation type, owning table, and definition. After taking a backup, remove or rename only the unrelated colliding table, index, or view, then retry `alembic upgrade head`. If either database reports `owner-aware UserOAuth schema has incorrect indexes`, keep workers stopped and compare both index columns, uniqueness flags, and predicates with the verification definitions below. Repair or remove the incorrect owner indexes under database-operator supervision before retrying the migration.

### Prerequisites and configuration

This change has no new environment variable or dependency. Keep every future actor-OAuth caller disabled; this release does not expose a production path that creates actor-owned rows.

### Deployment and migration steps

Choose the procedure for the configured database.

#### SQLite

1. Stop new OAuth connections and task execution.
2. Stop every API and task worker.
Comment thread
bsbds marked this conversation as resolved.
3. Take and verify a restorable database backup.
4. Deploy the new application files without starting workers.
5. Record `PRAGMA foreign_key_check;` and `SELECT count(*) FROM gmail_watch_states;`, then run `alembic upgrade head` one time.
6. Run both queries again. Require that the foreign-key result contains no new row and that the watch-state count is unchanged.
7. Start every API and task worker with the new version.
8. Verify the schema and homogeneous worker version.
9. Resume ordinary OAuth connections and task execution.

#### PostgreSQL

1. Pause OAuth reads and writes that access `user_oauth`, and make sure no long transaction holds a lock on the table.
2. Run `alembic upgrade head` one time. Already-running old workers can continue non-OAuth work while the transactional DDL runs, but an old worker that starts or restarts after the schema revision advances will fail startup because it does not recognize the new revision. Prevent old-version restarts and autoscaling during this window, or ensure every replacement starts from the owner-aware image.
3. Resume ordinary OAuth writes after the migration commits.
4. Roll every API and task worker to the owner-aware version.
5. Verify the schema and make sure no old worker remains before a later release enables actor-owned rows.

Do not backfill `resource_owner_key`. A null owner identifies an ordinary credential.

### Verification and monitoring

Run this query after the migration:

```sql
SELECT count(*)
FROM user_oauth
WHERE resource_owner_key IS NOT NULL;
```

The result must be zero.

On PostgreSQL, verify both partial unique index definitions:

```sql
SELECT
c.relname,
i.indisunique,
pg_get_expr(i.indpred, i.indrelid) AS predicate,
pg_get_indexdef(i.indexrelid) AS definition
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'user_oauth'
AND c.relname IN (
'uq_user_oauth_ordinary_account',
'uq_user_oauth_actor_account'
);
```

The query must return both rows with `indisunique = true`. The ordinary row must index `(user_id, provider, provider_user_id)` with `resource_owner_key IS NULL`; the actor row must index `(user_id, resource_owner_key, provider, provider_user_id)` with `resource_owner_key IS NOT NULL`.

For SQLite run `PRAGMA index_list('user_oauth');` and `PRAGMA index_info('<index-name>');`. Inspect `sqlite_master.sql` to confirm that the ordinary index uses `WHERE resource_owner_key IS NULL` and the actor index uses `WHERE resource_owner_key IS NOT NULL`.

Before restarting Gmail watch processing, run this query on either supported database:

```sql
SELECT count(*)
FROM gmail_watch_states AS watch
JOIN user_oauth AS account ON account.id = watch.oauth_account_id
WHERE watch.user_id <> account.user_id
OR account.resource_owner_key IS NOT NULL;
```

The result must be zero. A nonzero result identifies a legacy watch whose account owner does not match its user or whose account is not ordinary; repair or remove that watch before rollout.

Verify existing cloud-storage, Gmail, and builtin OAuth connections. Confirm that seeded non-null-owner test rows do not appear in ordinary catalog, token, or trigger paths.

### Rollback

Because this release cannot create actor-owned rows, the downgrade remains available after ordinary rollout.

1. Stop all workers before the downgrade.
2. If the database is SQLite, create a current database backup.
3. If the database is SQLite, run `PRAGMA integrity_check;` against the backup. The result must be `ok`.
4. Run `alembic downgrade b1efe0dbe0af`.
5. Run `alembic current`. The command must report only `b1efe0dbe0af`.
6. If the database is SQLite, run `PRAGMA integrity_check;` and `PRAGMA foreign_key_check;`.
7. If the database is SQLite, inspect `PRAGMA table_info('user_oauth');`. The result must not contain `resource_owner_key`.
8. If the database is SQLite, inspect `PRAGMA index_list('user_oauth');` and each `PRAGMA index_info('<index-name>');` result. One unique index must cover `(user_id, provider, provider_user_id)`.
9. Deploy the old version.

SQLite can commit each schema operation separately during a batch-table rebuild. If the downgrade fails or stops, do not retry against the changed database. Restore the verified backup, make sure that `alembic current` reports the owner-aware revision, and retry the downgrade.

The migration refuses the downgrade if a non-null owner row exists. If a caller created such a row, disable that caller. Revoke and remove the credential with an approved procedure. Then retry the downgrade.
4 changes: 4 additions & 0 deletions src/xagent/db/migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sqlalchemy.engine import Connection

from .config import create_alembic_config
from .migration_support import require_owner_aware_unique_index_dialect

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -392,6 +393,9 @@ def try_upgrade_db(
) -> None:
"""Upgrade database to latest migration (or stamp head for brand-new databases)."""
try:
# This precedes both Alembic revision work and the fresh-schema
# create_all path that follows try_upgrade_db during application start.
require_owner_aware_unique_index_dialect(engine.dialect.name)
logger.info("Starting database upgrade process")
if locked_connection is not None:
_upgrade_db_locked(
Expand Down
20 changes: 20 additions & 0 deletions src/xagent/db/migration_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Shared invariants used by startup and Alembic owner-aware migrations."""

from __future__ import annotations

OWNER_AWARE_UNIQUE_INDEX_DIALECTS = frozenset({"sqlite", "postgresql"})


def require_owner_aware_unique_index_dialect(dialect: object) -> str:
"""Return a supported dialect name or reject before schema inspection.

Actor/ordinary builtin OAuth identity relies on partial unique indexes.
Both application startup and the revision itself call this neutral helper
so their supported-dialect contract and diagnostic cannot drift.
"""
if not isinstance(dialect, str) or dialect not in OWNER_AWARE_UNIQUE_INDEX_DIALECTS:
raise RuntimeError(
"actor-owned builtin OAuth requires partial unique indexes; "
f"database dialect {dialect!r} is unsupported"
)
return dialect
20 changes: 10 additions & 10 deletions src/xagent/db/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,12 @@ def _set_sqlite_pragmas(dbapi_connection, _connection_record): # type: ignore[n


def _apply_concurrency_pragmas(cursor, timeout_ms: int) -> None: # type: ignore[no-untyped-def]
"""Set the SQLite runtime pragmas, best-effort.
"""Set optional concurrency pragmas and required relational integrity.

A connect hook that raises breaks every connection, so a pragma failure must
never propagate. On a read-only database (or a directory where the -wal/-shm
sidecars cannot be created) ``PRAGMA journal_mode=WAL`` raises; we log and
continue. ``busy_timeout`` is connection-local (no disk write) and is set
independently so it still applies when WAL is unavailable.
WAL and ``busy_timeout`` failures degrade concurrency but do not invalidate
stored relationships, so they remain best-effort. Foreign-key enforcement
is a required connection invariant: ORM relationships intentionally depend
on database cascades for rows hidden from filtered collections.
"""
try:
cursor.execute("PRAGMA journal_mode=WAL")
Expand All @@ -96,7 +95,8 @@ def _apply_concurrency_pragmas(cursor, timeout_ms: int) -> None: # type: ignore
cursor.execute(f"PRAGMA busy_timeout={timeout_ms}")
except Exception as exc: # noqa: BLE001
logger.warning("Could not set SQLite busy_timeout: %s", exc)
try:
cursor.execute("PRAGMA foreign_keys=ON")
except Exception as exc: # noqa: BLE001
logger.warning("Could not enable SQLite foreign keys: %s", exc)
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA foreign_keys")
enabled = cursor.fetchone()
if enabled is None or int(enabled[0]) != 1:
raise RuntimeError("SQLite foreign-key enforcement could not be enabled")
Loading
Loading