From 5c8644d5e1884e2d630a4e788efe6ee7c46eb930 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 10 Jul 2026 02:40:21 +0800 Subject: [PATCH] docs: plan sync tenant isolation --- .../plans/2026-07-10-sync-tenant-isolation.md | 95 +++++++++++++++++++ ...2026-07-10-sync-tenant-isolation-design.md | 79 +++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-sync-tenant-isolation.md create mode 100644 docs/superpowers/specs/2026-07-10-sync-tenant-isolation-design.md diff --git a/docs/superpowers/plans/2026-07-10-sync-tenant-isolation.md b/docs/superpowers/plans/2026-07-10-sync-tenant-isolation.md new file mode 100644 index 0000000..48ae305 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-sync-tenant-isolation.md @@ -0,0 +1,95 @@ +# Sync Tenant Isolation Implementation Plan + +> **For agentic workers:** execute this plan task by task with focused tests +> before each implementation change. + +**Goal:** prevent cross-user and cross-vault operation visibility while binding +the persisted source device to the authenticated token. + +**Architecture:** the server derives user, device, and vault scope from the +bearer token. SQLite rows carry that scope; desktop sends the immutable vault +ID only while creating a pairing. Existing unscoped devices and operations are +migrated into a deterministic legacy scope. + +**Tech stack:** Go, `database/sql`, SQLite, `net/http`, desktop Go sync client. + +## Task 1: Establish server behaviour tests + +**Files:** +- Modify: `internal/server/server_test.go` + +- [ ] Add helpers that create confirmed users and token-authenticated devices + with a specified vault ID. +- [ ] Add a failing test where separate users push and pull from the same + vault ID; each pull must contain only its own operation and cursor. +- [ ] Add a failing test where one user owns devices in two vaults; pulls must + remain vault-local. +- [ ] Add a failing test that sends another device's ID in `push`; assert the + stored and returned operation uses the authenticated device ID. +- [ ] Add a failing test for identical idempotency keys in different scopes. +- [ ] Run: `go test ./internal/server -run 'TestSync.*Isolation|TestSyncPush'` + and confirm the new assertions fail for the intended missing behaviour. + +## Task 2: Add idempotent SQLite scope migration + +**Files:** +- Modify: `internal/server/schema.go` +- Modify: `internal/server/server.go` +- Test: `internal/server/server_test.go` + +- [ ] Define `vault_id` on new devices and `user_id`/`vault_id` on new + operations; define scoped tombstone and idempotency primary keys. +- [ ] Add startup migration helpers that inspect columns, add compatible + columns, backfill owner IDs, assign `legacy:` to old scopes, and + rebuild the two tables whose primary keys change. +- [ ] Add a failing legacy-schema fixture test, then make it pass by opening + the database through `NewServer` and asserting its operation has the + expected owner and legacy scope. +- [ ] Run: `go test ./internal/server -run 'Test.*Migration|TestSync.*'`. + +## Task 3: Apply authenticated scope to sync handlers + +**Files:** +- Modify: `internal/server/middleware.go` +- Modify: `internal/server/handlers_api.go` +- Test: `internal/server/server_test.go` + +- [ ] Extend authenticated device lookup to provide the effective vault scope; + missing user ownership must not authorize sync operations. +- [ ] Require `vault_id` when creating a new client pairing and store it with + the device. +- [ ] Make push use authenticated device/user/vault values for inserts, + conflicts, revisions, tombstones, and idempotency lookup/storage. +- [ ] Make pull filter operations and its reported cursor by authenticated + user/vault. +- [ ] Run the focused tests from Task 1 until green, then + `go test ./internal/server`. + +## Task 4: Send the current vault ID while pairing + +**Files:** +- Modify: `../verstak-desktop/internal/core/sync/client.go` +- Modify: `../verstak-desktop/internal/core/sync/client_test.go` +- Modify: `../verstak-desktop/internal/api/app.go` +- Modify: `../verstak-desktop/internal/api/app_test.go` + +- [ ] Add `vault_id` to the pair request and expose it in the pairing client + method without changing push/pull wire compatibility. +- [ ] Read the open vault metadata in `syncConfigure`; reject configuration if + the vault ID is absent. +- [ ] Add a failing client/API test that captures the pair request and asserts + the persistent vault ID is sent. +- [ ] Run: `go test ./internal/core/sync ./internal/api`. + +## Task 5: Document, verify, and publish + +**Files:** +- Modify: `README.md` +- Modify: `docs/superpowers/specs/2026-07-10-sync-tenant-isolation-design.md` + +- [ ] Document that pairing is vault-bound and that sync cursors are scoped. +- [ ] Run `gofmt` on all changed Go files. +- [ ] Run `go test ./...` in both `verstak-sync-server` and + `verstak-desktop`, then `git diff --check` in both repositories. +- [ ] Commit and push the sync-server and desktop changes as coordinated + security commits. diff --git a/docs/superpowers/specs/2026-07-10-sync-tenant-isolation-design.md b/docs/superpowers/specs/2026-07-10-sync-tenant-isolation-design.md new file mode 100644 index 0000000..0cafe81 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-sync-tenant-isolation-design.md @@ -0,0 +1,79 @@ +# Sync Tenant Isolation Design + +**Status:** approved for implementation by the project owner on 2026-07-10. + +## Goal + +Make sync operations private to the authenticated user and vault, and make the +server, not the request body, the authority for the originating device. + +## Scope + +- Bind new desktop pairings to the current vault ID. +- Persist `user_id` and `vault_id` with every sync operation. +- Scope push conflict detection, pull cursors, tombstones, and idempotency + responses to that pair of identifiers. +- Ignore the legacy `device_id` field in a push body for authorization and + storage; retain it in the wire format for backward-compatible decoding. +- Upgrade existing SQLite databases without deleting data. + +Blob ownership, API-key retirement, reset-token handling, HTML escaping, and +upload limits are separate security slices and are deliberately not included +in this change. + +## Data model + +`server_devices` gains a nullable `vault_id`. A device created through +`/api/client/pair` must have a non-empty vault ID. The authenticated device +therefore identifies one user and one vault. + +`server_ops` gains `user_id` and `vault_id`. New writes always set both from +the authenticated device. Pull and conflict queries filter both fields. + +`server_tombstones` is rebuilt with a composite key of +`(user_id, vault_id, entity_type, entity_id)`. `server_idempotency_keys` is +rebuilt with a composite key of `(user_id, vault_id, idempotency_key)`. + +Existing devices without `vault_id` use the explicit effective scope +`legacy:`. Existing operations inherit their device owner and that +legacy scope during startup migration. This preserves existing single-vault +accounts while preventing data from crossing account boundaries. New pairings +never use the legacy scope. + +## API contract + +`POST /api/client/pair` accepts a required `vault_id`. The desktop gets it +from `.verstak/vault.json` and sends it while pairing. + +`POST /api/v1/sync/push` keeps accepting `device_id` for old clients, but the +server ignores it. The stored operation device ID is always the authenticated +device. A request whose token is not associated with a user and effective +vault returns a client error. + +`POST /api/v1/sync/pull` returns only operations from the authenticated +user/vault scope. `server_sequence` is the highest sequence in that scope; +global sequence gaps are not exposed as the caller's cursor. + +## Migration and failure handling + +Startup migration is idempotent. It checks SQLite table columns before adding +new operation/device fields, backfills `user_id` from each operation's device, +and assigns the explicit legacy scope when an old device has no vault ID. +Tables whose primary key must change are rebuilt transactionally. + +If an operation cannot be associated with a user, it remains unscoped and is +not readable through sync APIs. The server must not guess an owner from a +request body. + +## Verification + +Focused server tests must prove: + +1. two users cannot pull each other's operations; +2. two vaults of one user cannot pull each other's operations; +3. a caller cannot forge another device through the push body; +4. scoped idempotency does not replay another tenant's response; +5. a legacy SQLite database is upgraded with its existing operation retained + in the matching legacy scope. + +Desktop tests must prove that pairing sends the opened vault's persistent ID.