Skip to content

feat(api): encrypt Google tokens backfill + user data export (#115) - #181

Merged
thomasluizon merged 2 commits into
mainfrom
issue-115
Jun 4, 2026
Merged

feat(api): encrypt Google tokens backfill + user data export (#115)#181
thomasluizon merged 2 commits into
mainfrom
issue-115

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Jun 4, 2026

Copy link
Copy Markdown
Owner

Part of the Legal & Compliance umbrella. API side of paired PRs.

Scope

  • Encrypt Google OAuth tokens (backfill gap): Added User to the DataEncryptionMigrationService backfill loop. Versioned the one-shot flag (EncryptionMigrationCompleteEncryptionMigrationComplete_v2) so existing User rows with plaintext GoogleAccessToken/GoogleRefreshToken are re-encrypted once even where the original flag was already set. Converter wiring (OrbitDbContext) verified.
  • User data export: New ExportUserDataQuery + UserDataExport model aggregating all of the user's habits (with logs), goals (with progress logs), tags, AI memory facts, and settings. New authenticated GET /api/profile/export returns it as a downloadable JSON attachment (Content-Disposition: attachment). Registered the action in AgentCatalogService (account-lifecycle capability). LGPD Art. 18 / GDPR Art. 20 portability.

Tests

  • OrbitDbContextTests: model wires User Google tokens to the nullable encrypting converter + real-key round-trip proves ciphertext-at-rest (enc: prefix) and decrypt-on-read.
  • ExportUserDataTests (integration): returns a downloadable JSON attachment, contains created habit/log/goal/tag, and is scoped to the requesting user (a second user's export is empty).

Validation

  • dotnet build clean.
  • dotnet test: Application (1679) + Infrastructure (917) green; integration tests boot (stacked on the JWT fix) and connect to Postgres orbit_test — Profile/Export/Encryption/Auth/Habits suites pass.

Production note

The export excludes secrets (Google tokens, Stripe IDs). The encryption backfill is a no-op unless Encryption:Key is a real 256-bit key in prod (placeholder key = passthrough by design); confirm the prod env var is set so converters encrypt rather than pass through.

Refs thomasluizon/orbit-ui-mobile#115
Stacked on #176 (JWT integration-test fix).

Paired UI PR (DRAFT, needs-legal-review): thomasluizon/orbit-ui-mobile#131

🤖 Generated with Claude Code

thomasluizon and others added 2 commits June 4, 2026 14:27
WebApplicationFactory<Program> loads config from the Orbit.Api content
root, where appsettings.json carries a 27-byte "REPLACE-IN-DEVELOPMENT-JSON"
placeholder for Jwt:SecretKey. JwtSettings.Validate() requires >=32 bytes,
so every integration test threw at startup
("Jwt:SecretKey must be at least 32 bytes (27 provided)").

The test project's own appsettings.json is never loaded by the factory
(content root resolves to src/Orbit.Api), so its valid key was dead config.
Inject a test-only secret via UseSetting in the factory instead. Production
validator is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add User to the DataEncryptionMigrationService backfill loop (versioned the
EncryptionMigrationComplete flag to EncryptionMigrationComplete_v2 so existing
User rows with plaintext Google OAuth tokens are re-encrypted once). Verified
the GoogleAccessToken/GoogleRefreshToken converter wiring with a real-key
round-trip test.

Add an authenticated GET /api/profile/export (ExportUserDataQuery + UserDataExport
model) returning all of the user's habits, logs, goals, progress logs, tags, facts,
and settings as a downloadable JSON attachment (LGPD Art. 18 / GDPR Art. 20).
Integration-tested for content and per-user scoping.

Refs thomasluizon/orbit-ui-mobile#115

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid compliance feature. The data export query is correct — FindAsync takes Expression<Func<T,bool>> so the habitIds.Contains/goalIds.Contains predicates translate to SQL IN clauses, not in-memory filters. Authorization inherits from the class-level [Authorize] on ProfileController (confirmed, and the 401 integration test verifies it). Sensitive fields (Google tokens, Stripe IDs) are correctly excluded from the export model. The encryption backfill versioning strategy (EncryptionMigrationComplete_v2) is sound: EF Core value converters decrypt-on-read/encrypt-on-write, so re-running over already-encrypted rows is idempotent. Integration tests cover the three cases that matter — auth gating, file attachment headers, and user-data isolation. Nothing to block.

Base automatically changed from chore/fix-integration-jwt-secret to main June 4, 2026 23:54
@thomasluizon
thomasluizon merged commit 7fc5856 into main Jun 4, 2026
2 checks passed
@thomasluizon
thomasluizon deleted the issue-115 branch June 4, 2026 23:57
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