docs: plan sync tenant isolation
This commit is contained in:
parent
1084c91854
commit
5c8644d5e1
|
|
@ -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:<user_id>` 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.
|
||||||
|
|
@ -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:<user_id>`. 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.
|
||||||
Loading…
Reference in New Issue